mikro-orm/mikro-orm

View on GitHub
packages/core/src/EntityManager.ts

Summary

Maintainability
A
0 mins
Test Coverage
A
97%
import { inspect } from 'util';
import DataLoader from 'dataloader';
import {
  type Configuration,
  Cursor,
  DataloaderUtils,
  getOnConflictReturningFields,
  QueryHelper,
  TransactionContext,
  Utils,
} from './utils';
import {
  type AssignOptions,
  EntityAssigner,
  EntityFactory,
  EntityLoader,
  type EntityLoaderOptions,
  type EntityRepository,
  EntityValidator,
  helper,
  Reference,
} from './entity';
import { ChangeSet, ChangeSetType, UnitOfWork } from './unit-of-work';
import type {
  CountOptions,
  DeleteOptions,
  EntityField,
  FindAllOptions,
  FindByCursorOptions,
  FindOneOptions,
  FindOneOrFailOptions,
  FindOptions,
  GetReferenceOptions,
  IDatabaseDriver,
  LockOptions,
  NativeInsertUpdateOptions,
  UpdateOptions,
  UpsertManyOptions,
  UpsertOptions,
} from './drivers';
import type {
  AnyEntity,
  AnyString,
  ArrayElement,
  AutoPath,
  ConnectionType,
  Constructor,
  Dictionary,
  EntityData,
  EntityDictionary,
  EntityDTO,
  EntityKey,
  EntityMetadata,
  EntityName,
  FilterDef,
  FilterQuery,
  FromEntityType,
  GetRepository,
  IHydrator,
  IsSubset,
  Loaded,
  MaybePromise,
  MergeLoaded,
  MergeSelected,
  ObjectQuery,
  PopulateOptions,
  Primary,
  Ref,
  RequiredEntityData,
  UnboxArray,
  NoInfer,
} from './typings';
import {
  EventType,
  FlushMode,
  LoadStrategy,
  LockMode,
  PopulateHint,
  PopulatePath,
  QueryFlag,
  ReferenceKind,
  SCALAR_TYPES,
  type TransactionOptions,
} from './enums';
import type { MetadataStorage } from './metadata';
import type { Transaction } from './connections';
import { EventManager, type FlushEventArgs, TransactionEventBroadcaster } from './events';
import type { EntityComparator } from './utils/EntityComparator';
import { OptimisticLockError, ValidationError } from './errors';
import type { CacheAdapter } from './cache/CacheAdapter';

/**
 * The EntityManager is the central access point to ORM functionality. It is a facade to all different ORM subsystems
 * such as UnitOfWork, Query Language, and Repository API.
 * @template {IDatabaseDriver} Driver current driver type
 */
export class EntityManager<Driver extends IDatabaseDriver = IDatabaseDriver> {

  private static counter = 1;
  readonly _id = EntityManager.counter++;
  readonly global = false;
  readonly name: string;
  protected readonly refLoader = new DataLoader(DataloaderUtils.getRefBatchLoadFn(this));
  protected readonly colLoader = new DataLoader(DataloaderUtils.getColBatchLoadFn(this));
  private readonly validator: EntityValidator;
  private readonly repositoryMap: Dictionary<EntityRepository<any>> = {};
  private readonly entityLoader: EntityLoader;
  protected readonly comparator: EntityComparator;
  private readonly entityFactory: EntityFactory;
  private readonly unitOfWork: UnitOfWork;
  private readonly resultCache: CacheAdapter;
  private filters: Dictionary<FilterDef> = {};
  private filterParams: Dictionary<Dictionary> = {};
  protected loggerContext?: Dictionary;
  private transactionContext?: Transaction;
  private disableTransactions: boolean;
  private flushMode?: FlushMode;
  private _schema?: string;

  /**
   * @internal
   */
  constructor(readonly config: Configuration,
              protected readonly driver: Driver,
              protected readonly metadata: MetadataStorage,
              protected readonly useContext = true,
              protected readonly eventManager = new EventManager(config.get('subscribers'))) {
    this.entityLoader = new EntityLoader(this);
    this.name = this.config.get('contextName');
    this.validator = new EntityValidator(this.config.get('strict'));
    this.comparator = this.config.getComparator(this.metadata);
    this.resultCache = this.config.getResultCacheAdapter();
    this.disableTransactions = this.config.get('disableTransactions');
    this.entityFactory = new EntityFactory(this);
    this.unitOfWork = new UnitOfWork(this);
  }

  /**
   * Gets the Driver instance used by this EntityManager.
   * Driver is singleton, for one MikroORM instance, only one driver is created.
   */
  getDriver(): Driver {
    return this.driver;
  }

  /**
   * Gets the Connection instance, by default returns write connection
   */
  getConnection(type?: ConnectionType): ReturnType<Driver['getConnection']> {
    return this.driver.getConnection(type) as ReturnType<Driver['getConnection']>;
  }

  /**
   * Gets the platform instance. Just like the driver, platform is singleton, one for a MikroORM instance.
   */
  getPlatform(): ReturnType<Driver['getPlatform']> {
    return this.driver.getPlatform() as ReturnType<Driver['getPlatform']>;
  }

  /**
   * Gets repository for given entity. You can pass either string name or entity class reference.
   */
  getRepository<
    Entity extends object,
    Repository extends EntityRepository<Entity> = EntityRepository<Entity>,
  >(entityName: EntityName<Entity>): GetRepository<Entity, Repository> {
    entityName = Utils.className(entityName);

    if (!this.repositoryMap[entityName]) {
      const meta = this.metadata.get(entityName);
      const RepositoryClass = this.config.getRepositoryClass(meta.repository) as Constructor<EntityRepository<any>>;
      this.repositoryMap[entityName] = new RepositoryClass(this, entityName);
    }

    return this.repositoryMap[entityName] as unknown as GetRepository<Entity, Repository>;
  }

  /**
   * Shortcut for `em.getRepository()`.
   */
  repo<
    Entity extends object,
    Repository extends EntityRepository<Entity> = EntityRepository<Entity>,
  >(entityName: EntityName<Entity>): GetRepository<Entity, Repository> {
    return this.getRepository(entityName);
  }

  /**
   * Gets EntityValidator instance
   */
  getValidator(): EntityValidator {
    return this.validator;
  }

  /**
   * 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 = PopulatePath.ALL,
    Excludes extends string = never,
  >(entityName: EntityName<Entity>, where: FilterQuery<NoInfer<Entity>>, options: FindOptions<Entity, Hint, Fields, Excludes> = {}): Promise<Loaded<Entity, Hint, Fields, Excludes>[]> {
    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 });
      fork.clear();

      return ret;
    }

    const em = this.getContext();
    em.prepareOptions(options);
    await em.tryFlush(entityName, options);
    entityName = Utils.className(entityName);
    where = await em.processWhere(entityName, where, options, 'read') as FilterQuery<Entity>;
    em.validator.validateParams(where);
    options.orderBy = options.orderBy || {};
    options.populate = await em.preparePopulate(entityName, options) as any;
    const populate = options.populate as unknown as PopulateOptions<Entity>[];
    const cacheKey = em.cacheKey(entityName, options, 'em.find', where);
    const cached = await em.tryCache<Entity, Loaded<Entity, Hint, Fields, Excludes>[]>(entityName, options.cache, cacheKey, options.refresh, true);

    if (cached?.data) {
      await em.entityLoader.populate<Entity>(entityName, cached.data as Entity[], populate, {
        ...options as Dictionary,
        ...em.getPopulateWhere(where as ObjectQuery<Entity>, options),
        convertCustomTypes: false,
        ignoreLazyScalarProperties: true,
        lookup: false,
      });

      return cached.data;
    }

    const meta = this.metadata.get<Entity>(entityName);
    options = { ...options };
    // save the original hint value so we know it was infer/all
    (options as Dictionary)._populateWhere = options.populateWhere ?? this.config.get('populateWhere');
    options.populateWhere = await this.applyJoinedFilters(meta, { ...where } as ObjectQuery<Entity>, options);
    const results = await em.driver.find(entityName, where, { ctx: em.transactionContext, ...options });

    if (results.length === 0) {
      await em.storeCache(options.cache, cached!, []);
      return [];
    }

    const ret: Loaded<Entity, Hint, Fields, Excludes>[] = [];

    for (const data of results) {
      const entity = em.entityFactory.create(entityName, data as EntityData<Entity>, {
        merge: true,
        refresh: options.refresh,
        schema: options.schema,
        convertCustomTypes: true,
      }) as Loaded<Entity, Hint, Fields, Excludes>;

      ret.push(entity);
    }

    const unique = Utils.unique(ret);
    await em.entityLoader.populate<Entity, Fields>(entityName, unique as Entity[], populate, {
      ...options as Dictionary,
      ...em.getPopulateWhere(where as ObjectQuery<Entity>, options),
      convertCustomTypes: false,
      ignoreLazyScalarProperties: true,
      lookup: false,
    });
    await em.unitOfWork.dispatchOnLoadEvent();

    if (meta.virtual) {
      await em.storeCache(options.cache, cached!, () => ret);
    } else {
      await em.storeCache(options.cache, cached!, () => unique.map(e => helper(e).toPOJO()));
    }

    return unique;
  }

  /**
   * Finds all entities of given type, optionally matching the `where` condition provided in the `options` parameter.
   */
  async findAll<
    Entity extends object,
    Hint extends string = never,
    Fields extends string = '*',
    Excludes extends string = never,
  >(entityName: EntityName<Entity>, options?: FindAllOptions<NoInfer<Entity>, Hint, Fields, Excludes>): Promise<Loaded<Entity, Hint, Fields, Excludes>[]> {
    return this.find(entityName, options?.where ?? {}, options);
  }

  private getPopulateWhere<
    Entity extends object,
    Hint extends string = never,
  >(where: ObjectQuery<Entity>, options: Pick<FindOptions<Entity, Hint>, 'populateWhere'>): { where: ObjectQuery<Entity>; populateWhere?: PopulateHint | `${PopulateHint}` } {
    if (options.populateWhere === undefined) {
      options.populateWhere = this.config.get('populateWhere');
    }

    if (options.populateWhere === PopulateHint.ALL) {
      return { where: {} as ObjectQuery<Entity>, populateWhere: options.populateWhere };
    }

    /* istanbul ignore next */
    if (options.populateWhere === PopulateHint.INFER) {
      return { where, populateWhere: options.populateWhere };
    }

    return { where: options.populateWhere as ObjectQuery<Entity> };
  }

  /**
   * Registers global filter to this entity manager. Global filters are enabled by default (unless disabled via last parameter).
   */
  addFilter<T1>(name: string, cond: FilterQuery<T1> | ((args: Dictionary) => MaybePromise<FilterQuery<T1>>), entityName?: EntityName<T1> | [EntityName<T1>], enabled?: boolean): void;

  /**
   * Registers global filter to this entity manager. Global filters are enabled by default (unless disabled via last parameter).
   */
  addFilter<T1, T2>(name: string, cond: FilterQuery<T1 | T2> | ((args: Dictionary) => MaybePromise<FilterQuery<T1 | T2>>), entityName?: [EntityName<T1>, EntityName<T2>], enabled?: boolean): void;

  /**
   * Registers global filter to this entity manager. Global filters are enabled by default (unless disabled via last parameter).
   */
  addFilter<T1, T2, T3>(name: string, cond: FilterQuery<T1 | T2 | T3> | ((args: Dictionary) => MaybePromise<FilterQuery<T1 | T2 | T3>>), entityName?: [EntityName<T1>, EntityName<T2>, EntityName<T3>], enabled?: boolean): void;

  /**
   * Registers global filter to this entity manager. Global filters are enabled by default (unless disabled via last parameter).
   */
  addFilter(name: string, cond: Dictionary | ((args: Dictionary) => MaybePromise<FilterQuery<AnyEntity>>), entityName?: EntityName<AnyEntity> | EntityName<AnyEntity>[], enabled?: boolean): void;

  /**
   * Registers global filter to this entity manager. Global filters are enabled by default (unless disabled via last parameter).
   */
  addFilter(name: string, cond: Dictionary | ((args: Dictionary) => MaybePromise<FilterQuery<AnyEntity>>), entityName?: EntityName<AnyEntity> | EntityName<AnyEntity>[], enabled = true): void {
    const options: FilterDef = { name, cond, default: enabled };

    if (entityName) {
      options.entity = Utils.asArray(entityName).map(n => Utils.className(n));
    }

    this.getContext(false).filters[name] = options;
  }

  /**
   * Sets filter parameter values globally inside context defined by this entity manager.
   * If you want to set shared value for all contexts, be sure to use the root entity manager.
   */
  setFilterParams(name: string, args: Dictionary): void {
    this.getContext().filterParams[name] = args;
  }

  /**
   * Returns filter parameters for given filter set in this context.
   */
  getFilterParams<T extends Dictionary = Dictionary>(name: string): T {
    return this.getContext().filterParams[name] as T;
  }

  /**
   * Sets logger context for this entity manager.
   */
  setLoggerContext(context: Dictionary): void {
    this.getContext().loggerContext = context;
  }

  /**
   * Gets logger context for this entity manager.
   */
  getLoggerContext<T extends Dictionary = Dictionary>(): T {
    const em = this.getContext();
    em.loggerContext ??= {};

    return em.loggerContext as T;
  }

  setFlushMode(flushMode?: FlushMode): void {
    this.getContext(false).flushMode = flushMode;
  }

  protected async processWhere<
    Entity extends object,
    Hint extends string = never,
    Fields extends string = '*',
    Excludes extends string = never,
  >(entityName: string, where: FilterQuery<Entity>, options: FindOptions<Entity, Hint, Fields, Excludes> | FindOneOptions<Entity, Hint, Fields, Excludes>, type: 'read' | 'update' | 'delete'): Promise<FilterQuery<Entity>> {
    where = QueryHelper.processWhere({
      where,
      entityName,
      metadata: this.metadata,
      platform: this.driver.getPlatform(),
      convertCustomTypes: options.convertCustomTypes,
      aliased: type === 'read',
    });
    where = (await this.applyFilters(entityName, where, options.filters ?? {}, type, options))!;
    where = await this.applyDiscriminatorCondition(entityName, where);

    return where;
  }

  // this method only handles the problem for mongo driver, SQL drivers have their implementation inside QueryBuilder
  protected applyDiscriminatorCondition<Entity extends object>(entityName: string, where: FilterQuery<Entity>): FilterQuery<Entity> {
    const meta = this.metadata.find<Entity>(entityName);

    if (!meta?.discriminatorValue) {
      return where;
    }

    const types = Object.values(meta.root.discriminatorMap!).map(cls => this.metadata.find(cls)!);
    const children: EntityMetadata[] = [];
    const lookUpChildren = (ret: EntityMetadata[], type: string) => {
      const children = types.filter(meta2 => meta2.extends === type);
      children.forEach(m => lookUpChildren(ret, m.className));
      ret.push(...children.filter(c => c.discriminatorValue));

      return children;
    };
    lookUpChildren(children, meta.className);
    /* istanbul ignore next */
    (where as Dictionary)[meta.root.discriminatorColumn!] = children.length > 0 ? { $in: [meta.discriminatorValue, ...children.map(c => c.discriminatorValue)] } : meta.discriminatorValue;

    return where;
  }

  protected async applyJoinedFilters<Entity extends object>(meta: EntityMetadata<Entity>, cond: ObjectQuery<Entity>, options: FindOptions<Entity, any, any, any> | FindOneOptions<Entity, any, any, any>): Promise<ObjectQuery<Entity>> {
    const ret = {} as ObjectQuery<Entity>;
    const populateWhere = options.populateWhere ?? this.config.get('populateWhere');

    if (populateWhere === PopulateHint.INFER) {
      Utils.merge(ret, cond);
    } else if (typeof populateWhere === 'object') {
      Utils.merge(ret, populateWhere);
    }

    if (options.populate) {
      for (const hint of (options.populate as unknown as PopulateOptions<Entity>[])) {
        const field = hint.field.split(':')[0] as EntityKey<Entity>;
        const prop = meta.properties[field];
        const joined = (prop.strategy || options.strategy || hint.strategy || this.config.get('loadStrategy')) === LoadStrategy.JOINED && prop.kind !== ReferenceKind.SCALAR;

        if (!joined && !hint.filter) {
          continue;
        }

        const where = await this.applyFilters<Entity>(prop.type, {}, options.filters ?? {}, 'read', { ...options, populate: hint.children });
        const where2 = await this.applyJoinedFilters<Entity>(prop.targetMeta!, {} as ObjectQuery<Entity>, { ...options, populate: hint.children as any, populateWhere: PopulateHint.ALL });

        if (Utils.hasObjectKeys(where!)) {
          ret[field] = ret[field] ? { $and: [where, ret[field]] } : where as any;
        }

        if (Utils.hasObjectKeys(where2)) {
          if (ret[field]) {
            Utils.merge(ret[field], where2);
          } else {
            ret[field] = where2 as any;
          }
        }
      }
    }

    return ret;
  }

  /**
   * When filters are active on M:1 or 1:1 relations, we need to ref join them eagerly as they might affect the FK value.
   */
  protected async autoJoinRefsForFilters<T extends object>(meta: EntityMetadata<T>, options: FindOptions<T, any, any, any> | FindOneOptions<T, any, any, any>): Promise<void> {
    if (!meta || !this.config.get('autoJoinRefsForFilters')) {
      return;
    }

    const props = meta.relations.filter(prop => {
      return !prop.object && [ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind)
        && ((options.fields?.length ?? 0) === 0 || options.fields?.some(f => prop.name === f || prop.name.startsWith(`${String(f)}.`)));
    });
    const ret = options.populate as PopulateOptions<T>[];

    for (const prop of props) {
      const cond = await this.applyFilters(prop.type, {}, options.filters ?? {}, 'read', options);

      if (!Utils.isEmpty(cond)) {
        const populated = (options.populate as PopulateOptions<T>[]).filter(({ field }) => field.split(':')[0] === prop.name);

        if (populated.length > 0) {
          populated.forEach(hint => hint.filter = true);
        } else {
          ret.push({ field: `${prop.name}:ref` as any, strategy: LoadStrategy.JOINED, filter: true });
        }
      }
    }
  }

  /**
   * @internal
   */
  async applyFilters<Entity extends object>(
    entityName: string,
    where: FilterQuery<Entity> | undefined,
    options: Dictionary<boolean | Dictionary> | string[] | boolean,
    type: 'read' | 'update' | 'delete',
    findOptions?: FindOptions<any, any, any, any> | FindOneOptions<any, any, any, any>,
  ): Promise<FilterQuery<Entity> | undefined> {
    const meta = this.metadata.find<Entity>(entityName);
    const filters: FilterDef[] = [];
    const ret: Dictionary[] = [];

    if (!meta) {
      return where;
    }

    const active = new Set<string>();
    const push = (source: Dictionary<FilterDef>) => {
      const activeFilters = QueryHelper
        .getActiveFilters(entityName, options, source)
        .filter(f => !active.has(f.name));
      filters.push(...activeFilters);
      activeFilters.forEach(f => active.add(f.name));
    };
    push(this.config.get('filters'));
    push(this.filters);
    push(meta.filters);

    if (filters.length === 0) {
      return where;
    }

    for (const filter of filters) {
      let cond: Dictionary;

      if (filter.cond instanceof Function) {
        // @ts-ignore
        const args = Utils.isPlainObject(options[filter.name]) ? options[filter.name] : this.getContext().filterParams[filter.name];

        if (!args && filter.cond.length > 0 && filter.args !== false) {
          throw new Error(`No arguments provided for filter '${filter.name}'`);
        }

        cond = await filter.cond(args, type, this, findOptions);
      } else {
        cond = filter.cond;
      }

      ret.push(QueryHelper.processWhere({
        where: cond,
        entityName,
        metadata: this.metadata,
        platform: this.driver.getPlatform(),
        aliased: type === 'read',
      }));
    }

    const conds = [...ret, where as Dictionary].filter(c => Utils.hasObjectKeys(c)) as FilterQuery<Entity>[];

    return conds.length > 1 ? { $and: conds } as FilterQuery<Entity> : conds[0];
  }

  /**
   * 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 = '*',
    Excludes extends string = never,
  >(entityName: EntityName<Entity>, where: FilterQuery<NoInfer<Entity>>, options: FindOptions<Entity, Hint, Fields, Excludes> = {}): Promise<[Loaded<Entity, Hint, Fields, Excludes>[], number]> {
    const em = this.getContext(false);
    const copy = Utils.copy(where);
    const [entities, count] = await Promise.all([
      em.find(entityName, where, options),
      em.count(entityName, copy, options as CountOptions<Entity, Hint>),
    ]);

    return [entities, count];
  }

  /**
   * Calls `em.find()` and `em.count()` with the same arguments (where applicable) and returns the results as {@apilink Cursor} object.
   * Supports `before`, `after`, `first` and `last` options while disallowing `limit` and `offset`. Explicit `orderBy` option
   * is required.
   *
   * Use `first` and `after` for forward pagination, or `last` and `before` for backward pagination.
   *
   * - `first` and `last` are numbers and serve as an alternative to `offset`, those options are mutually exclusive, use only one at a time
   * - `before` and `after` specify the previous cursor value, it can be one of the:
   *     - `Cursor` instance
   *     - opaque string provided by `startCursor/endCursor` properties
   *     - POJO/entity instance
   *
   * ```ts
   * const currentCursor = await em.findByCursor(User, {}, {
   *   first: 10,
   *   after: previousCursor, // cursor instance
   *   orderBy: { id: 'desc' },
   * });
   *
   * // to fetch next page
   * const nextCursor = await em.findByCursor(User, {}, {
   *   first: 10,
   *   after: currentCursor.endCursor, // opaque string
   *   orderBy: { id: 'desc' },
   * });
   *
   * // to fetch next page
   * const nextCursor2 = await em.findByCursor(User, {}, {
   *   first: 10,
   *   after: { id: lastSeenId }, // entity-like POJO
   *   orderBy: { id: 'desc' },
   * });
   * ```
   *
   * The `Cursor` object provides the following interface:
   *
   * ```ts
   * Cursor<User> {
   *   items: [
   *     User { ... },
   *     User { ... },
   *     User { ... },
   *   ],
   *   totalCount: 50,
   *   startCursor: 'WzRd',
   *   endCursor: 'WzZd',
   *   hasPrevPage: true,
   *   hasNextPage: true,
   * }
   * ```
   */
  async findByCursor<
    Entity extends object,
    Hint extends string = never,
    Fields extends string = '*',
    Excludes extends string = never,
  >(entityName: EntityName<Entity>, where: FilterQuery<NoInfer<Entity>>, options: FindByCursorOptions<Entity, Hint, Fields, Excludes>): Promise<Cursor<Entity, Hint, Fields, Excludes>> {
    const em = this.getContext(false);
    entityName = Utils.className(entityName);
    options.overfetch ??= true;

    if (Utils.isEmpty(options.orderBy)) {
      throw new Error('Explicit `orderBy` option required');
    }

    const [entities, count] = await em.findAndCount(entityName, where, options);

    return new Cursor(entities, count, options, this.metadata.get(entityName));
  }

  /**
   * Refreshes the persistent state of an entity from the database, overriding any local changes that have not yet been
   * persisted. Returns the same entity instance (same object reference), but re-hydrated. If the entity is no longer
   * in database, the method throws an error just like `em.findOneOrFail()` (and respects the same config options).
   */
  async refreshOrFail<
    Entity extends object,
    Naked extends FromEntityType<Entity> = FromEntityType<Entity>,
    Hint extends string = never,
    Fields extends string = '*',
    Excludes extends string = never,
  >(entity: Entity, options: FindOneOrFailOptions<Entity, Hint, Fields, Excludes> = {}): Promise<MergeLoaded<Entity, Naked, Hint, Fields, Excludes, true>> {
    const ret = await this.refresh(entity, options);

    if (!ret) {
      options.failHandler ??= this.config.get('findOneOrFailHandler');
      const entityName = entity.constructor.name;
      const where = helper(entity).getPrimaryKey() as any;
      throw options.failHandler!(entityName, where);
    }

    return ret as any;
  }

  /**
   * Refreshes the persistent state of an entity from the database, overriding any local changes that have not yet been
   * persisted. Returns the same entity instance (same object reference), but re-hydrated. If the entity is no longer
   * in database, the method returns `null`.
   */
  async refresh<
    Entity extends object,
    Naked extends FromEntityType<Entity> = FromEntityType<Entity>,
    Hint extends string = never,
    Fields extends string = '*',
    Excludes extends string = never,
  >(entity: Entity, options: FindOneOptions<Entity, Hint, Fields, Excludes> = {}): Promise<MergeLoaded<Entity, Naked, Hint, Fields, Excludes, true> | null> {
    const fork = this.fork();
    const entityName = entity.constructor.name;
    const reloaded = await fork.findOne(entityName, entity, {
      schema: helper(entity).__schema,
      ...options,
      flushMode: FlushMode.COMMIT,
    });

    if (reloaded) {
      this.config.getHydrator(this.metadata).hydrate(
        entity,
        helper(entity).__meta,
        helper(reloaded).toPOJO() as object,
        this.getEntityFactory(),
        'full',
      );
    } else {
      this.getUnitOfWork().unsetIdentity(entity);
    }

    return reloaded ? entity as any : reloaded;
  }

  /**
   * Finds first entity matching your `where` query.
   */
  async findOne<
    Entity extends object,
    Hint extends string = never,
    Fields extends string = '*',
    Excludes extends string = never,
  >(entityName: EntityName<Entity>, where: FilterQuery<NoInfer<Entity>>, options: FindOneOptions<Entity, Hint, Fields, Excludes> = {}): Promise<Loaded<Entity, Hint, Fields, Excludes> | null> {
    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 });
      fork.clear();

      return ret;
    }

    const em = this.getContext();
    entityName = Utils.className(entityName);
    em.prepareOptions(options);
    let entity = em.unitOfWork.tryGetById<Entity>(entityName, where, options.schema);

    // query for a not managed entity which is already in the identity map as it
    // was provided with a PK this entity does not exist in the db, there can't
    // be any relations to it, so no need to deal with the populate hint
    if (entity && !helper(entity).__managed) {
      return entity as Loaded<Entity, Hint, Fields, Excludes>;
    }

    await em.tryFlush(entityName, options);
    const meta = em.metadata.get<Entity>(entityName);
    where = await em.processWhere(entityName, where, options, 'read');
    em.validator.validateEmptyWhere(where);
    em.checkLockRequirements(options.lockMode, meta);
    const isOptimisticLocking = !Utils.isDefined(options.lockMode) || options.lockMode === LockMode.OPTIMISTIC;

    if (entity && !em.shouldRefresh(meta, entity, options) && isOptimisticLocking) {
      return em.lockAndPopulate(meta, entity, where, options);
    }

    em.validator.validateParams(where);
    options.populate = await em.preparePopulate(entityName, options) as any;
    const cacheKey = em.cacheKey(entityName, options, 'em.findOne', where);
    const cached = await em.tryCache<Entity, Loaded<Entity, Hint, Fields, Excludes>>(entityName, options.cache, cacheKey, options.refresh, true);

    if (cached?.data) {
      await em.entityLoader.populate<Entity, Fields>(entityName, [cached.data as Entity], options.populate as unknown as PopulateOptions<Entity>[], {
        ...options as Dictionary,
        ...em.getPopulateWhere(where as ObjectQuery<Entity>, options),
        convertCustomTypes: false,
        ignoreLazyScalarProperties: true,
        lookup: false,
      });

      return cached.data;
    }

    options = { ...options };
    // save the original hint value so we know it was infer/all
    (options as Dictionary)._populateWhere = options.populateWhere ?? this.config.get('populateWhere');
    options.populateWhere = await this.applyJoinedFilters(meta, { ...where } as ObjectQuery<Entity>, options);
    const data = await em.driver.findOne<Entity, Hint, Fields, Excludes>(entityName, where, {
      ctx: em.transactionContext,
      ...options,
    });

    if (!data) {
      await em.storeCache(options.cache, cached!, null);
      return null;
    }

    entity = em.entityFactory.create<Entity>(entityName, data as EntityData<Entity>, {
      merge: true,
      refresh: options.refresh,
      schema: options.schema,
      convertCustomTypes: true,
    });

    await em.lockAndPopulate(meta, entity, where, options);
    await em.unitOfWork.dispatchOnLoadEvent();
    await em.storeCache(options.cache, cached!, () => helper(entity!).toPOJO());

    return entity as Loaded<Entity, Hint, Fields, Excludes>;
  }

  /**
   * Finds first entity matching your `where` query. If nothing found, it will throw an error.
   * If the `strict` option is specified and nothing is found or more than one matching entity is found, it will throw an error.
   * 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 = '*',
    Excludes extends string = never,
  >(entityName: EntityName<Entity>, where: FilterQuery<NoInfer<Entity>>, options: FindOneOrFailOptions<Entity, Hint, Fields, Excludes> = {}): Promise<Loaded<Entity, Hint, Fields, Excludes>> {
    let entity: Loaded<Entity, Hint, Fields, Excludes> | null;
    let isStrictViolation = false;

    if (options.strict) {
      const ret = await this.find(entityName, where, { ...options, limit: 2 } as FindOptions<Entity, Hint, Fields, Excludes>);
      isStrictViolation = ret.length !== 1;
      entity = ret[0];
    } else {
      entity = await this.findOne<Entity, Hint, Fields, Excludes>(entityName, where, options);
    }

    if (!entity || isStrictViolation) {
      const key = options.strict ? 'findExactlyOneOrFailHandler' : 'findOneOrFailHandler';
      options.failHandler ??= this.config.get(key);
      entityName = Utils.className(entityName);
      /* istanbul ignore next */
      where = Utils.isEntity(where) ? helper(where).getPrimaryKey() as any : where;
      throw options.failHandler!(entityName, where);
    }

    return entity as Loaded<Entity, Hint, Fields, Excludes>;
  }

  /**
   * Creates or updates the entity, based on whether it is already present in the database.
   * This method performs an `insert on conflict merge` query ensuring the database is in sync, returning a managed
   * entity instance. The method accepts either `entityName` together with the entity `data`, or just entity instance.
   *
   * ```ts
   * // insert into "author" ("age", "email") values (33, 'foo@bar.com') on conflict ("email") do update set "age" = 41
   * const author = await em.upsert(Author, { email: 'foo@bar.com', age: 33 });
   * ```
   *
   * The entity data needs to contain either the primary key, or any other unique property. Let's consider the following example, where `Author.email` is a unique property:
   *
   * ```ts
   * // insert into "author" ("age", "email") values (33, 'foo@bar.com') on conflict ("email") do update set "age" = 41
   * // select "id" from "author" where "email" = 'foo@bar.com'
   * const author = await em.upsert(Author, { email: 'foo@bar.com', age: 33 });
   * ```
   *
   * Depending on the driver support, this will either use a returning query, or a separate select query, to fetch the primary key if it's missing from the `data`.
   *
   * If the entity is already present in current context, there won't be any queries - instead, the entity data will be assigned and an explicit `flush` will be required for those changes to be persisted.
   */
  async upsert<Entity extends object>(entityNameOrEntity: EntityName<Entity> | Entity, data?: EntityData<Entity> | NoInfer<Entity>, options: UpsertOptions<Entity> = {}): Promise<Entity> {
    const em = this.getContext(false);
    em.prepareOptions(options);

    let entityName: EntityName<Entity>;
    let where: FilterQuery<Entity>;
    let entity: Entity;

    if (data === undefined) {
      entityName = (entityNameOrEntity as Dictionary).constructor.name;
      data = entityNameOrEntity as Entity;
    } else {
      entityName = Utils.className(entityNameOrEntity as EntityName<Entity>);
    }

    const meta = this.metadata.get<Entity>(entityName);
    const convertCustomTypes = !Utils.isEntity(data);

    if (Utils.isEntity(data)) {
      entity = data as Entity;

      if (helper(entity).__managed && helper(entity).__em === em) {
        em.entityFactory.mergeData(meta, entity, data, { initialized: true });
        return entity;
      }

      where = helper(entity).getPrimaryKey() as FilterQuery<Entity>;
      data = em.comparator.prepareEntity(entity);
    } else {
      data = Utils.copy(QueryHelper.processParams(data));
      where = Utils.extractPK(data, meta) as FilterQuery<Entity>;

      if (where) {
        const exists = em.unitOfWork.getById<Entity>(entityName, where as Primary<Entity>, options.schema);

        if (exists) {
          return em.assign(exists, data as any) as any;
        }
      }
    }

    const unique = options.onConflictFields as string[] ?? meta.props.filter(p => p.unique).map(p => p.name);
    const propIndex = unique.findIndex(p => (data as Dictionary)[p] != null);

    if (options.onConflictFields || where == null) {
      if (propIndex >= 0) {
        where = { [unique[propIndex]]: (data as Dictionary)[unique[propIndex]] } as FilterQuery<Entity>;
      } else if (meta.uniques.length > 0) {
        for (const u of meta.uniques) {
          if (Utils.asArray<EntityKey<Entity>>(u.properties).every(p => data![p] != null)) {
            where = Utils.asArray<EntityKey<Entity>>(u.properties).reduce((o, key) => {
              o[key] = data![key];
              return o;
            }, {} as any);
            break;
          }
        }
      }
    }

    if (where == null) {
      const compositeUniqueProps = meta.uniques.map(u => Utils.asArray(u.properties).join(' + ')) as EntityKey<Entity>[];
      const uniqueProps = meta.primaryKeys.concat(...unique as EntityKey[]).concat(compositeUniqueProps);
      throw new Error(`Unique property value required for upsert, provide one of: ${uniqueProps.join(', ')}`);
    }

    data = QueryHelper.processObjectParams(data) as EntityData<Entity>;
    em.validator.validateParams(data, 'insert data');

    if (em.eventManager.hasListeners(EventType.beforeUpsert, meta)) {
      await em.eventManager.dispatchEvent(EventType.beforeUpsert, { entity: data as Entity, em, meta }, meta);
    }

    const ret = await em.driver.nativeUpdate(entityName, where, data, {
      ctx: em.transactionContext,
      upsert: true,
      convertCustomTypes,
      ...options,
    });

    entity ??= em.entityFactory.create(entityName, data, {
      refresh: true,
      initialized: true,
      schema: options.schema,
      convertCustomTypes: true,
    });

    em.unitOfWork.getChangeSetPersister().mapReturnedValues(entity, data, ret.row, meta);
    const uniqueFields = options.onConflictFields ?? (Utils.isPlainObject(where) ? Object.keys(where) : meta!.primaryKeys) as (keyof Entity)[];
    const returning = getOnConflictReturningFields(meta, data, uniqueFields, options) as string[];

    if (options.onConflictAction === 'ignore' || !helper(entity).hasPrimaryKey() || (returning.length > 0 && !(this.getPlatform().usesReturningStatement() && ret.row))) {
      const where = {} as FilterQuery<Entity>;
      uniqueFields.forEach(prop => where[prop as EntityKey] = data![prop as EntityKey]);
      const data2 = await this.driver.findOne(meta.className, where, {
        fields: returning as any[],
        ctx: em.transactionContext,
        convertCustomTypes: true,
        connectionType: 'write',
      });
      em.getHydrator().hydrate(entity, meta, data2!, em.entityFactory, 'full');
    }

    // recompute the data as there might be some values missing (e.g. those with db column defaults)
    const snapshot = this.comparator.prepareEntity(entity);
    em.unitOfWork.register(entity, snapshot, { refresh: true });

    if (em.eventManager.hasListeners(EventType.afterUpsert, meta)) {
      await em.eventManager.dispatchEvent(EventType.afterUpsert, { entity, em, meta }, meta);
    }

    return entity;
  }

  /**
   * Creates or updates the entity, based on whether it is already present in the database.
   * This method performs an `insert on conflict merge` query ensuring the database is in sync, returning a managed
   * entity instance. The method accepts either `entityName` together with the entity `data`, or just entity instance.
   *
   * ```ts
   * // insert into "author" ("age", "email") values (33, 'foo@bar.com') on conflict ("email") do update set "age" = 41
   * const authors = await em.upsertMany(Author, [{ email: 'foo@bar.com', age: 33 }, ...]);
   * ```
   *
   * The entity data needs to contain either the primary key, or any other unique property. Let's consider the following example, where `Author.email` is a unique property:
   *
   * ```ts
   * // insert into "author" ("age", "email") values (33, 'foo@bar.com'), (666, 'lol@lol.lol') on conflict ("email") do update set "age" = excluded."age"
   * // select "id" from "author" where "email" = 'foo@bar.com'
   * const author = await em.upsertMany(Author, [
   *   { email: 'foo@bar.com', age: 33 },
   *   { email: 'lol@lol.lol', age: 666 },
   * ]);
   * ```
   *
   * Depending on the driver support, this will either use a returning query, or a separate select query, to fetch the primary key if it's missing from the `data`.
   *
   * If the entity is already present in current context, there won't be any queries - instead, the entity data will be assigned and an explicit `flush` will be required for those changes to be persisted.
   */
  async upsertMany<Entity extends object>(entityNameOrEntity: EntityName<Entity> | Entity[], data?: (EntityData<Entity> | NoInfer<Entity>)[], options: UpsertManyOptions<Entity> = {}): Promise<Entity[]> {
    const em = this.getContext(false);
    em.prepareOptions(options);

    let entityName: string;
    let propIndex: number;

    if (data === undefined) {
      entityName = (entityNameOrEntity as Entity[])[0].constructor.name;
      data = entityNameOrEntity as Entity[];
    } else {
      entityName = Utils.className(entityNameOrEntity as EntityName<Entity>);
    }

    const batchSize = options.batchSize ?? this.config.get('batchSize');

    if (data.length > batchSize) {
      const ret: Entity[] = [];

      for (let i = 0; i < data.length; i += batchSize) {
        const chunk = data.slice(i, i + batchSize);
        ret.push(...await this.upsertMany(entityName, chunk, options));
      }

      return ret;
    }

    const meta = this.metadata.get<Entity>(entityName);
    const convertCustomTypes = !Utils.isEntity(data[0]);
    const allData: EntityData<Entity>[] = [];
    const allWhere: FilterQuery<Entity>[] = [];
    const entities = new Map<Entity, EntityData<Entity>>();
    const entitiesByData = new Map<EntityData<Entity>, Entity>();

    for (let i = 0; i < data.length; i++) {
      let row = data[i];
      let where: FilterQuery<Entity>;

      if (Utils.isEntity(row)) {
        const entity = row as Entity;

        if (helper(entity).__managed && helper(entity).__em === em) {
          em.entityFactory.mergeData(meta, entity, row, { initialized: true });
          entities.set(entity, row);
          entitiesByData.set(row, entity);
          continue;
        }

        where = helper(entity).getPrimaryKey() as FilterQuery<Entity>;
        row = em.comparator.prepareEntity(entity);
      } else {
        row = data[i] = Utils.copy(QueryHelper.processParams(row));
        where = Utils.extractPK(row, meta) as FilterQuery<Entity>;

        if (where) {
          const exists = em.unitOfWork.getById<Entity>(entityName, where as Primary<Entity>, options.schema);

          if (exists) {
            em.assign(exists, row as any);
            entities.set(exists, row);
            entitiesByData.set(row, exists);
            continue;
          }
        }
      }

      const unique = meta.props.filter(p => p.unique).map(p => p.name);
      propIndex = unique.findIndex(p => row[p] != null);

      if (options.onConflictFields || where == null) {
        if (propIndex >= 0) {
          where = { [unique[propIndex]]: row[unique[propIndex] as EntityKey<Entity>] } as FilterQuery<Entity>;
        } else if (meta.uniques.length > 0) {
          for (const u of meta.uniques) {
            if (Utils.asArray<EntityKey<Entity>>(u.properties).every(p => row[p] != null)) {
              where = Utils.asArray<EntityKey<Entity>>(u.properties).reduce((o, key) => {
                o[key] = row[key];
                return o;
              }, {} as Dictionary) as FilterQuery<Entity>;
              break;
            }
          }
        }
      }

      if (where == null) {
        const compositeUniqueProps = meta.uniques.map(u => Utils.asArray(u.properties).join(' + '));
        const uniqueProps = (meta.primaryKeys as string[]).concat(...unique).concat(compositeUniqueProps);
        throw new Error(`Unique property value required for upsert, provide one of: ${uniqueProps.join(', ')}`);
      }

      row = QueryHelper.processObjectParams(row) as EntityData<Entity>;
      where = QueryHelper.processWhere({
        where,
        entityName,
        metadata: this.metadata,
        platform: this.getPlatform(),
      });
      em.validator.validateParams(row, 'insert data');
      allData.push(row);
      allWhere.push(where);
    }

    if (entities.size === data.length) {
      return [...entities.keys()];
    }

    if (em.eventManager.hasListeners(EventType.beforeUpsert, meta)) {
      for (const dto of data) {
        const entity = entitiesByData.get(dto) ?? dto as Entity;
        await em.eventManager.dispatchEvent(EventType.beforeUpsert, { entity, em, meta }, meta);
      }
    }

    const res = await em.driver.nativeUpdateMany(entityName, allWhere, allData, {
      ctx: em.transactionContext,
      upsert: true,
      convertCustomTypes,
      ...options,
    });

    entities.clear();
    entitiesByData.clear();
    const loadPK = new Map<Entity, FilterQuery<Entity>>();

    allData.forEach((row, i) => {
      const entity = Utils.isEntity(data![i]) ? data![i] as Entity : em.entityFactory.create(entityName, row, {
        refresh: true,
        initialized: true,
        schema: options.schema,
        convertCustomTypes: true,
      });

      em.unitOfWork.getChangeSetPersister().mapReturnedValues(entity, Utils.isEntity(data![i]) ? {} : data![i], res.rows?.[i], meta);

      if (!helper(entity).hasPrimaryKey()) {
        loadPK.set(entity, allWhere[i]);
      }

      entities.set(entity, row);
      entitiesByData.set(row, entity);
    });

    // skip if we got the PKs via returning statement (`rows`)
    const uniqueFields = options.onConflictFields ?? (Utils.isPlainObject(allWhere![0]) ? Object.keys(allWhere![0]).flatMap(key => Utils.splitPrimaryKeys(key)) : meta!.primaryKeys) as (keyof Entity)[];
    const returning = getOnConflictReturningFields(meta, data[0], uniqueFields, options) as string[];
    const reloadFields = returning.length > 0 && !(this.getPlatform().usesReturningStatement() && res.rows?.length);

    if (options.onConflictAction === 'ignore' || (!res.rows?.length && loadPK.size > 0) || reloadFields) {
      const unique = meta.hydrateProps.filter(p => !p.lazy).map(p => p.name);
      const add = new Set(propIndex! >= 0 ? [unique[propIndex!]] : [] as EntityKey<Entity>[]);

      for (const cond of loadPK.values()) {
        Utils.keys(cond).forEach(key => add.add(key as EntityKey));
      }

      const where = { $or: [] as Dictionary[] };
      data.forEach((item, idx) => {
        where.$or[idx] = {};
        uniqueFields.forEach(prop => {
          where.$or[idx][prop as string] = item[prop as EntityKey];
        });
      });

      const data2 = await this.driver.find(meta.className, where, {
        fields: returning.concat(...add).concat(...uniqueFields as string[]) as any,
        ctx: em.transactionContext,
        convertCustomTypes: true,
        connectionType: 'write',
      });

      for (const [entity, cond] of loadPK.entries()) {
        const row = data2.find(row => {
          const tmp: Dictionary = {};
          add.forEach(k => {
            if (!meta.properties[k]?.primary) {
              tmp[k] = row[k];
            }
          });
          return this.comparator.matching(entityName, cond as EntityKey, tmp);
        });

        /* istanbul ignore next */
        if (!row) {
          throw new Error(`Cannot find matching entity for condition ${JSON.stringify(cond)}`);
        }

        em.getHydrator().hydrate(entity, meta, row, em.entityFactory, 'full');
      }

      if (loadPK.size !== data2.length) {
        for (let i = 0; i < allData.length; i++) {
          const data = allData[i];
          const cond = uniqueFields.reduce((a, b) => {
            // @ts-ignore
            a[b] = data[b];
            return a;
          }, {});
          const entity = entitiesByData.get(data);
          const row = data2.find(item => {
            const pk = uniqueFields.reduce((a, b) => {
              // @ts-ignore
              a[b] = item[b];
              return a;
            }, {});
            return this.comparator.matching(entityName, cond, pk);
          });

          /* istanbul ignore next */
          if (!row) {
            throw new Error(`Cannot find matching entity for condition ${JSON.stringify(cond)}`);
          }

          em.getHydrator().hydrate(entity, meta, row, em.entityFactory, 'full');
        }
      }
    }

    for (const [entity] of entities) {
      // recompute the data as there might be some values missing (e.g. those with db column defaults)
      const snapshot = this.comparator.prepareEntity(entity);
      em.unitOfWork.register(entity, snapshot, { refresh: true });
    }

    if (em.eventManager.hasListeners(EventType.afterUpsert, meta)) {
      for (const [entity] of entities) {
        await em.eventManager.dispatchEvent(EventType.afterUpsert, { entity, em, meta }, meta);
      }
    }

    return [...entities.keys()];
  }

  /**
   * Runs your callback wrapped inside a database transaction.
   */
  async transactional<T>(cb: (em: this) => Promise<T>, options: TransactionOptions = {}): Promise<T> {
    const em = this.getContext(false);

    if (this.disableTransactions || em.disableTransactions) {
      return cb(em);
    }

    const fork = em.fork({
      clear: options.clear ?? false, // state will be merged once resolves
      flushMode: options.flushMode,
      cloneEventManager: true,
      disableTransactions: options.ignoreNestedTransactions,
      loggerContext: options.loggerContext,
    });
    options.ctx ??= em.transactionContext;
    const propagateToUpperContext = !em.global || this.config.get('allowGlobalContext');

    return TransactionContext.create(fork, async () => {
      return fork.getConnection().transactional(async trx => {
        fork.transactionContext = trx;

        if (propagateToUpperContext) {
          fork.eventManager.registerSubscriber({
            afterFlush(args: FlushEventArgs) {
              args.uow.getChangeSets()
                .filter(cs => [ChangeSetType.DELETE, ChangeSetType.DELETE_EARLY].includes(cs.type))
                .forEach(cs => em.unitOfWork.unsetIdentity(cs.entity));
            },
          });
        }

        const ret = await cb(fork);
        await fork.flush();

        if (propagateToUpperContext) {
          // ensure all entities from inner context are merged to the upper one
          for (const entity of fork.unitOfWork.getIdentityMap()) {
            em.unitOfWork.register(entity);
            entity.__helper!.__em = em;
          }
        }

        return ret;
      }, { ...options, eventBroadcaster: new TransactionEventBroadcaster(fork, undefined, { topLevelTransaction: !options.ctx }) });
    });
  }

  /**
   * Starts new transaction bound to this EntityManager. Use `ctx` parameter to provide the parent when nesting transactions.
   */
  async begin(options: Omit<TransactionOptions, 'ignoreNestedTransactions'> = {}): Promise<void> {
    if (this.disableTransactions) {
      return;
    }

    const em = this.getContext(false);
    em.transactionContext = await em.getConnection('write').begin({
      ...options,
      eventBroadcaster: new TransactionEventBroadcaster(em, undefined, { topLevelTransaction: !options.ctx }),
    });
  }

  /**
   * Commits the transaction bound to this EntityManager. Flushes before doing the actual commit query.
   */
  async commit(): Promise<void> {
    const em = this.getContext(false);

    if (this.disableTransactions) {
      await em.flush();
      return;
    }

    if (!em.transactionContext) {
      throw ValidationError.transactionRequired();
    }

    await em.flush();
    await em.getConnection('write').commit(em.transactionContext, new TransactionEventBroadcaster(em));
    delete em.transactionContext;
  }

  /**
   * Rollbacks the transaction bound to this EntityManager.
   */
  async rollback(): Promise<void> {
    if (this.disableTransactions) {
      return;
    }

    const em = this.getContext(false);

    if (!em.transactionContext) {
      throw ValidationError.transactionRequired();
    }

    await em.getConnection('write').rollback(em.transactionContext, new TransactionEventBroadcaster(em));
    delete em.transactionContext;
    em.unitOfWork.clearActionsQueue();
  }

  /**
   * Runs your callback wrapped inside a database transaction.
   */
  async lock<T extends object>(entity: T, lockMode: LockMode, options: LockOptions | number | Date = {}): Promise<void> {
    options = Utils.isPlainObject(options) ? options as LockOptions : { lockVersion: options };
    await this.getUnitOfWork().lock(entity, { lockMode, ...options });
  }

  /**
   * Fires native insert query. Calling this has no side effects on the context (identity map).
   */
  async insert<Entity extends object>(entityNameOrEntity: EntityName<Entity> | Entity, data?: RequiredEntityData<Entity> | Entity, options: NativeInsertUpdateOptions<Entity> = {}): Promise<Primary<Entity>> {
    const em = this.getContext(false);
    em.prepareOptions(options);

    let entityName;

    if (data === undefined) {
      entityName = (entityNameOrEntity as Dictionary).constructor.name;
      data = entityNameOrEntity as Entity;
    } else {
      entityName = Utils.className(entityNameOrEntity as EntityName<Entity>);
    }

    if (Utils.isEntity<Entity>(data)) {
      if (options.schema && helper(data).getSchema() == null) {
        helper(data).setSchema(options.schema);
      }

      if (!helper(data).__managed) {
        // the entity might have been created via `em.create()`, which adds it to the persist stack automatically
        em.unitOfWork.getPersistStack().delete(data);
        // it can be also in the identity map if it had a PK value already
        em.unitOfWork.unsetIdentity(data);
      }

      const meta = helper(data).__meta;
      const payload = em.comparator.prepareEntity(data);
      const cs = new ChangeSet(data, ChangeSetType.CREATE, payload, meta);
      await em.unitOfWork.getChangeSetPersister().executeInserts([cs], { ctx: em.transactionContext, ...options });

      return cs.getPrimaryKey()!;
    }

    data = QueryHelper.processObjectParams(data);
    em.validator.validateParams(data, 'insert data');
    const res = await em.driver.nativeInsert<Entity>(entityName, data as EntityData<Entity>, { ctx: em.transactionContext, ...options });

    return res.insertId!;
  }

  /**
   * Fires native multi-insert query. Calling this has no side effects on the context (identity map).
   */
  async insertMany<Entity extends object>(entityNameOrEntities: EntityName<Entity> | Entity[], data?: RequiredEntityData<Entity>[] | Entity[], options: NativeInsertUpdateOptions<Entity> = {}): Promise<Primary<Entity>[]> {
    const em = this.getContext(false);
    em.prepareOptions(options);

    let entityName;

    if (data === undefined) {
      entityName = ((entityNameOrEntities as Entity[])[0] as Dictionary).constructor.name;
      data = entityNameOrEntities as Entity[];
    } else {
      entityName = Utils.className(entityNameOrEntities as EntityName<Entity>);
    }

    if (data.length === 0) {
      return [];
    }

    if (Utils.isEntity<Entity>(data[0])) {
      const meta = helper<Entity>(data[0]).__meta;
      const css = data.map(row => {
        if (options.schema && helper(row).getSchema() == null) {
          helper(row).setSchema(options.schema);
        }

        if (!helper(row).__managed) {
          // the entity might have been created via `em.create()`, which adds it to the persist stack automatically
          em.unitOfWork.getPersistStack().delete(row);

          // it can be also in the identity map if it had a PK value already
          em.unitOfWork.unsetIdentity(row);
        }

        const payload = em.comparator.prepareEntity(row) as EntityData<Entity>;
        return new ChangeSet<Entity>(row as Entity, ChangeSetType.CREATE, payload, meta);
      });
      await em.unitOfWork.getChangeSetPersister().executeInserts(css, { ctx: em.transactionContext, ...options });

      return css.map(cs => cs.getPrimaryKey()!);
    }

    data = data.map(row => QueryHelper.processObjectParams(row));
    data.forEach(row => em.validator.validateParams(row, 'insert data'));
    const res = await em.driver.nativeInsertMany<Entity>(entityName, data as EntityData<Entity>[], { ctx: em.transactionContext, ...options });

    if (res.insertedIds) {
      return res.insertedIds;
    }

    return [res.insertId];
  }

  /**
   * Fires native update query. Calling this has no side effects on the context (identity map).
   */
  async nativeUpdate<Entity extends object>(entityName: EntityName<Entity>, where: FilterQuery<NoInfer<Entity>>, data: EntityData<Entity>, options: UpdateOptions<Entity> = {}): Promise<number> {
    const em = this.getContext(false);
    em.prepareOptions(options);

    entityName = Utils.className(entityName);
    data = QueryHelper.processObjectParams(data);
    where = await em.processWhere(entityName, where, options, 'update');
    em.validator.validateParams(data, 'update data');
    em.validator.validateParams(where, 'update condition');
    const res = await em.driver.nativeUpdate(entityName, where, data, { ctx: em.transactionContext, ...options });

    return res.affectedRows;
  }

  /**
   * Fires native delete query. Calling this has no side effects on the context (identity map).
   */
  async nativeDelete<Entity extends object>(entityName: EntityName<Entity>, where: FilterQuery<NoInfer<Entity>>, options: DeleteOptions<Entity> = {}): Promise<number> {
    const em = this.getContext(false);
    em.prepareOptions(options);

    entityName = Utils.className(entityName);
    where = await em.processWhere(entityName, where, options, 'delete');
    em.validator.validateParams(where, 'delete condition');
    const res = await em.driver.nativeDelete(entityName, where, { ctx: em.transactionContext, ...options });

    return res.affectedRows;
  }

  /**
   * Maps raw database result to an entity and merges it to this EntityManager.
   */
  map<Entity extends object>(entityName: EntityName<Entity>, result: EntityDictionary<Entity>, options: { schema?: string } = {}): Entity {
    entityName = Utils.className(entityName);
    const meta = this.metadata.get(entityName);
    const data = this.driver.mapResult(result, meta) as Dictionary;

    Object.keys(data).forEach(k => {
      const prop = meta.properties[k];

      if (prop && prop.kind === ReferenceKind.SCALAR && SCALAR_TYPES.includes(prop.runtimeType) && !prop.customType && (prop.setter || !prop.getter)) {
        data[k] = this.validator.validateProperty(prop, data[k], data);
      }
    });

    return this.merge<Entity>(entityName, data as EntityData<Entity>, {
      convertCustomTypes: true,
      refresh: true, ...options,
    });
  }

  /**
   * Merges given entity to this EntityManager so it becomes managed. You can force refreshing of existing entities
   * via second parameter. By default, it will return already loaded entities without modifying them.
   */
  merge<Entity extends object>(entity: Entity, options?: MergeOptions): Entity;

  /**
   * Merges given entity to this EntityManager so it becomes managed. You can force refreshing of existing entities
   * via second parameter. By default, it will return already loaded entities without modifying them.
   */
  merge<Entity extends object>(entityName: EntityName<Entity>, data: EntityData<Entity> | EntityDTO<Entity>, options?: MergeOptions): Entity;

  /**
   * Merges given entity to this EntityManager so it becomes managed. You can force refreshing of existing entities
   * via second parameter. By default, it will return already loaded entities without modifying them.
   */
  merge<Entity extends object>(entityName: EntityName<Entity> | Entity, data?: EntityData<Entity> | EntityDTO<Entity> | MergeOptions, options: MergeOptions = {}): Entity {
    const em = this.getContext();

    if (Utils.isEntity(entityName)) {
      return em.merge((entityName as Dictionary).constructor.name, entityName as unknown as EntityData<Entity>, data as MergeOptions);
    }

    options.schema ??= em._schema;
    entityName = Utils.className(entityName as string);
    em.validator.validatePrimaryKey(data as EntityData<Entity>, em.metadata.get(entityName));
    let entity = em.unitOfWork.tryGetById<Entity>(entityName, data as FilterQuery<Entity>, options.schema, false);

    if (entity && helper(entity).__managed && helper(entity).__initialized && !options.refresh) {
      return entity;
    }

    const meta = em.metadata.find(entityName)!;
    const childMeta = em.metadata.getByDiscriminatorColumn(meta, data as EntityData<Entity>);

    entity = Utils.isEntity<Entity>(data) ? data : em.entityFactory.create<Entity>(entityName, data as EntityData<Entity>, { merge: true, ...options });
    em.validator.validate(entity, data, childMeta ?? meta);
    em.unitOfWork.merge(entity);

    return entity!;
  }

  /**
   * Creates new instance of given entity and populates it with given data.
   * The entity constructor will be used unless you provide `{ managed: true }` in the options parameter.
   * The constructor will be given parameters based on the defined constructor of the entity. If the constructor
   * parameter matches a property name, its value will be extracted from `data`. If no matching property exists,
   * the whole `data` parameter will be passed. This means we can also define `constructor(data: Partial<T>)` and
   * `em.create()` will pass the data into it (unless we have a property named `data` too).
   *
   * The parameters are strictly checked, you need to provide all required properties. You can use `OptionalProps`
   * symbol to omit some properties from this check without making them optional. Alternatively, use `partial: true`
   * in the options to disable the strict checks for required properties. This option has no effect on runtime.
   */
  create<Entity extends object, Convert extends boolean = false>(entityName: EntityName<Entity>, data: RequiredEntityData<Entity, never, Convert>, options?: CreateOptions<Convert>): Entity;

  /**
   * Creates new instance of given entity and populates it with given data.
   * The entity constructor will be used unless you provide `{ managed: true }` in the options parameter.
   * The constructor will be given parameters based on the defined constructor of the entity. If the constructor
   * parameter matches a property name, its value will be extracted from `data`. If no matching property exists,
   * the whole `data` parameter will be passed. This means we can also define `constructor(data: Partial<T>)` and
   * `em.create()` will pass the data into it (unless we have a property named `data` too).
   *
   * The parameters are strictly checked, you need to provide all required properties. You can use `OptionalProps`
   * symbol to omit some properties from this check without making them optional. Alternatively, use `partial: true`
   * in the options to disable the strict checks for required properties. This option has no effect on runtime.
   */
  create<Entity extends object, Convert extends boolean = false>(entityName: EntityName<Entity>, data: EntityData<Entity, Convert>, options: CreateOptions<Convert> & { partial: true }): Entity;

  /**
   * Creates new instance of given entity and populates it with given data.
   * The entity constructor will be used unless you provide `{ managed: true }` in the options parameter.
   * The constructor will be given parameters based on the defined constructor of the entity. If the constructor
   * parameter matches a property name, its value will be extracted from `data`. If no matching property exists,
   * the whole `data` parameter will be passed. This means we can also define `constructor(data: Partial<T>)` and
   * `em.create()` will pass the data into it (unless we have a property named `data` too).
   *
   The parameters are strictly checked, you need to provide all required properties. You can use `OptionalProps`
   symbol to omit some properties from this check without making them optional. Alternatively, use `partial: true`
   in the options to disable the strict checks for required properties. This option has no effect on runtime.
   */
  create<Entity extends object, Convert extends boolean = false>(entityName: EntityName<Entity>, data: RequiredEntityData<Entity, never, Convert>, options: CreateOptions<Convert> = {}): Entity {
    const em = this.getContext();
    options.schema ??= em._schema;
    const entity = em.entityFactory.create(entityName, data as EntityData<Entity>, {
      ...options,
      newEntity: !options.managed,
      merge: options.managed,
    });
    options.persist ??= em.config.get('persistOnCreate');

    if (options.persist) {
      em.persist(entity);
    }

    return entity;
  }

  /**
   * Shortcut for `wrap(entity).assign(data, { em })`
   */
  assign<
    Entity extends object,
    Naked extends FromEntityType<Entity> = FromEntityType<Entity>,
    Convert extends boolean = false,
    Data extends EntityData<Naked, Convert> | Partial<EntityDTO<Naked>> = EntityData<Naked, Convert> | Partial<EntityDTO<Naked>>,
  >(entity: Entity | Partial<Entity>, data: Data & IsSubset<EntityData<Naked, Convert>, Data>, options: AssignOptions<Convert> = {}): MergeSelected<Entity, Naked, keyof Data & string> {
    return EntityAssigner.assign(entity, data as any, { em: this.getContext(), ...options }) as any;
  }

  /**
   * Gets a reference to the entity identified by the given type and identifier without actually loading it, if the entity is not yet loaded
   */
  getReference<Entity extends object>(entityName: EntityName<Entity>, id: Primary<Entity>, options: Omit<GetReferenceOptions, 'wrapped'> & { wrapped: true }): Ref<Entity>;

  /**
   * Gets a reference to the entity identified by the given type and identifier without actually loading it, if the entity is not yet loaded
   */
  getReference<Entity extends object>(entityName: EntityName<Entity>, id: Primary<Entity> | Primary<Entity>[]): Entity;

  /**
   * Gets a reference to the entity identified by the given type and identifier without actually loading it, if the entity is not yet loaded
   */
  getReference<Entity extends object>(entityName: EntityName<Entity>, id: Primary<Entity>, options: Omit<GetReferenceOptions, 'wrapped'> & { wrapped: false }): Entity;

  /**
   * Gets a reference to the entity identified by the given type and identifier without actually loading it, if the entity is not yet loaded
   */
  getReference<Entity extends object>(entityName: EntityName<Entity>, id: Primary<Entity>, options?: GetReferenceOptions): Entity | Reference<Entity>;

  /**
   * Gets a reference to the entity identified by the given type and identifier without actually loading it, if the entity is not yet loaded
   */
  getReference<Entity extends object>(entityName: EntityName<Entity>, id: Primary<Entity>, options: GetReferenceOptions = {}): Entity | Ref<Entity> | Reference<Entity> {
    options.schema ??= this.schema;
    options.convertCustomTypes ??= false;
    const meta = this.metadata.get(Utils.className(entityName));

    if (Utils.isPrimaryKey(id)) {
      if (meta.compositePK) {
        throw ValidationError.invalidCompositeIdentifier(meta);
      }

      id = [id] as Primary<Entity>;
    }

    const entity = this.getEntityFactory().createReference<Entity>(entityName, id, { merge: true, ...options });

    if (options.wrapped) {
      return Reference.create(entity);
    }

    return entity;
  }

  /**
   * Returns total number of entities matching your `where` query.
   */
  async count<
    Entity extends object,
    Hint extends string = never,
  >(entityName: EntityName<Entity>, where: FilterQuery<NoInfer<Entity>> = {} as FilterQuery<Entity>, options: CountOptions<Entity, Hint> = {}): Promise<number> {
    const em = this.getContext(false);

    // Shallow copy options since the object will be modified when deleting orderBy
    options = {
      schema: em._schema,
      ...options,
    };
    entityName = Utils.className(entityName);
    where = await em.processWhere(entityName, where, options as FindOptions<Entity, Hint>, 'read') as FilterQuery<Entity>;
    options.populate = await em.preparePopulate(entityName, options as FindOptions<Entity, Hint>) as any;
    em.validator.validateParams(where);
    delete (options as FindOptions<Entity>).orderBy;

    const cacheKey = em.cacheKey(entityName, options, 'em.count', where);
    const cached = await em.tryCache<Entity, number>(entityName, options.cache, cacheKey);

    if (cached?.data) {
      return cached.data as number;
    }

    const count = await em.driver.count<Entity, Hint>(entityName, where, { ctx: em.transactionContext, ...options });
    await em.storeCache(options.cache, cached!, () => +count);

    return +count;
  }

  /**
   * Tells the EntityManager to make an instance managed and persistent.
   * The entity will be entered into the database at or before transaction commit or as a result of the flush operation.
   */
  persist<Entity extends object>(entity: Entity | Reference<Entity> | Iterable<Entity | Reference<Entity>>): this {
    const em = this.getContext();

    if (Utils.isEntity(entity)) {
      // do not cascade just yet, cascading of entities in persist stack is done when flushing
      em.unitOfWork.persist(entity, undefined, { cascade: false });
      return em;
    }

    const entities = Utils.asArray(entity);

    for (const ent of entities) {
      if (!Utils.isEntity(ent, true)) {
        /* istanbul ignore next */
        const meta = typeof ent === 'object' ? em.metadata.find((ent as Dictionary).constructor.name) : undefined;
        throw ValidationError.notDiscoveredEntity(ent, meta);
      }

      // do not cascade just yet, cascading of entities in persist stack is done when flushing
      em.unitOfWork.persist(Reference.unwrapReference(ent), undefined, { cascade: false });
    }

    return this;
  }

  /**
   * Persists your entity immediately, flushing all not yet persisted changes to the database too.
   * Equivalent to `em.persist(e).flush()`.
   */
  async persistAndFlush(entity: AnyEntity | Reference<AnyEntity> | Iterable<AnyEntity | Reference<AnyEntity>>): Promise<void> {
    await this.persist(entity).flush();
  }

  /**
   * Marks entity for removal.
   * A removed entity will be removed from the database at or before transaction commit or as a result of the flush operation.
   *
   * To remove entities by condition, use `em.nativeDelete()`.
   */
  remove<Entity extends object>(entity: Entity | Reference<Entity> | Iterable<Entity | Reference<Entity>>): this {
    const em = this.getContext();

    if (Utils.isEntity<Entity>(entity)) {
      // do not cascade just yet, cascading of entities in persist stack is done when flushing
      em.unitOfWork.remove(entity, undefined, { cascade: false });
      return em;
    }

    const entities = Utils.asArray(entity, true);

    for (const ent of entities) {
      if (!Utils.isEntity(ent, true)) {
        throw new Error(`You need to pass entity instance or reference to 'em.remove()'. To remove entities by condition, use 'em.nativeDelete()'.`);
      }

      // do not cascade just yet, cascading of entities in remove stack is done when flushing
      em.unitOfWork.remove(Reference.unwrapReference(ent), undefined, { cascade: false });
    }

    return em;
  }

  /**
   * Removes an entity instance immediately, flushing all not yet persisted changes to the database too.
   * Equivalent to `em.remove(e).flush()`
   */
  async removeAndFlush(entity: AnyEntity | Reference<AnyEntity> | Iterable<AnyEntity | Reference<AnyEntity>>): Promise<void> {
    await this.remove(entity).flush();
  }

  /**
   * Flushes all changes to objects that have been queued up to now to the database.
   * This effectively synchronizes the in-memory state of managed objects with the database.
   */
  async flush(): Promise<void> {
    await this.getUnitOfWork().commit();
  }

  /**
   * @internal
   */
  async tryFlush<Entity extends object>(entityName: EntityName<Entity>, options: { flushMode?: FlushMode | AnyString }): Promise<void> {
    const em = this.getContext();
    const flushMode = options.flushMode ?? em.flushMode ?? em.config.get('flushMode');
    entityName = Utils.className(entityName);
    const meta = em.metadata.get(entityName);

    if (flushMode === FlushMode.COMMIT) {
      return;
    }

    if (flushMode === FlushMode.ALWAYS || em.getUnitOfWork().shouldAutoFlush(meta)) {
      await em.flush();
    }
  }

  /**
   * Clears the EntityManager. All entities that are currently managed by this EntityManager become detached.
   */
  clear(): void {
    this.getContext().unitOfWork.clear();
  }

  /**
   * Checks whether given property can be populated on the entity.
   */
  canPopulate<Entity extends object>(entityName: EntityName<Entity>, property: string): boolean {
    entityName = Utils.className(entityName);
    // eslint-disable-next-line prefer-const
    let [p, ...parts] = property.split('.');
    const meta = this.metadata.find(entityName);

    if (!meta) {
      return true;
    }

    if (p.includes(':')) {
      p = p.split(':', 2)[0];
    }

    const ret = p in meta.root.properties;

    if (!ret) {
      return !!this.metadata.find(property)?.pivotTable;
    }

    if (parts.length > 0) {
      return this.canPopulate((meta.root.properties)[p].type, parts.join('.'));
    }

    return ret;
  }

  /**
   * Loads specified relations in batch. This will execute one query for each relation, that will populate it on all the specified entities.
   */
  async populate<
    Entity extends object,
    Naked extends FromEntityType<UnboxArray<Entity>> = FromEntityType<UnboxArray<Entity>>,
    Hint extends string = never,
    Fields extends string = '*',
    Excludes extends string = never,
  >(entities: Entity, populate: AutoPath<Naked, Hint, PopulatePath.ALL>[] | false, options: EntityLoaderOptions<Naked, Fields, Excludes> = {}): Promise<Entity extends object[] ? MergeLoaded<ArrayElement<Entity>, Naked, Hint, Fields, Excludes>[] : MergeLoaded<Entity, Naked, Hint, Fields, Excludes>> {
    const arr = Utils.asArray(entities);

    if (arr.length === 0) {
      return entities as any;
    }

    const em = this.getContext();
    em.prepareOptions(options);
    const entityName = (arr[0] as Dictionary).constructor.name;
    const preparedPopulate = await em.preparePopulate<Entity>(entityName, { populate: populate as any }, options.validate);
    await em.entityLoader.populate(entityName, arr, preparedPopulate, options as EntityLoaderOptions<Entity>);

    return entities as any;
  }

  /**
   * Returns new EntityManager instance with its own identity map
   */
  fork(options: ForkOptions = {}): this {
    const em = options.disableContextResolution ? this : this.getContext(false);
    options.clear ??= true;
    options.useContext ??= false;
    options.freshEventManager ??= false;
    options.cloneEventManager ??= false;

    const eventManager = options.freshEventManager
      ? new EventManager(em.config.get('subscribers'))
      : options.cloneEventManager
        ? em.eventManager.clone()
        : em.eventManager;

    // we need to allow global context here as forking from global EM is fine
    const allowGlobalContext = em.config.get('allowGlobalContext');
    em.config.set('allowGlobalContext', true);
    const fork = new (em.constructor as typeof EntityManager)(em.config, em.driver, em.metadata, options.useContext, eventManager);
    fork.setFlushMode(options.flushMode ?? em.flushMode);
    fork.disableTransactions = options.disableTransactions ?? this.disableTransactions ?? this.config.get('disableTransactions');
    em.config.set('allowGlobalContext', allowGlobalContext);

    if (options.keepTransactionContext) {
      fork.transactionContext = em.transactionContext;
    }

    fork.filters = { ...em.filters };
    fork.filterParams = Utils.copy(em.filterParams);
    fork.loggerContext = Utils.merge({}, em.loggerContext, options.loggerContext);
    fork._schema = options.schema ?? em._schema;

    if (!options.clear) {
      for (const entity of em.unitOfWork.getIdentityMap()) {
        fork.unitOfWork.register(entity);
      }

      for (const entity of em.unitOfWork.getOrphanRemoveStack()) {
        fork.unitOfWork.getOrphanRemoveStack().add(entity);
      }
    }

    return fork as this;
  }

  /**
   * Gets the UnitOfWork used by the EntityManager to coordinate operations.
   */
  getUnitOfWork(useContext = true): UnitOfWork {
    if (!useContext) {
      return this.unitOfWork;
    }

    return this.getContext().unitOfWork;
  }

  /**
   * Gets the EntityFactory used by the EntityManager.
   */
  getEntityFactory(): EntityFactory {
    return this.getContext().entityFactory;
  }

  /**
   * Gets the Hydrator used by the EntityManager.
   */
  getHydrator(): IHydrator {
    return this.config.getHydrator(this.getMetadata());
  }

  /**
   * Gets the EntityManager based on current transaction/request context.
   * @internal
   */
  getContext(validate = true): this {
    if (!this.useContext) {
      return this;
    }

    let em = TransactionContext.getEntityManager(this.name) as this; // prefer the tx context

    if (em) {
      return em;
    }

    // no explicit tx started
    em = this.config.get('context')(this.name) as this ?? this;

    if (validate && !this.config.get('allowGlobalContext') && em.global) {
      throw ValidationError.cannotUseGlobalContext();
    }

    return em;
  }

  getEventManager(): EventManager {
    return this.eventManager;
  }

  /**
   * Checks whether this EntityManager is currently operating inside a database transaction.
   */
  isInTransaction(): boolean {
    return !!this.transactionContext;
  }

  /**
   * Gets the transaction context (driver dependent object used to make sure queries are executed on same connection).
   */
  getTransactionContext<T extends Transaction = Transaction>(): T | undefined {
    return this.transactionContext as T;
  }

  /**
   * Sets the transaction context.
   */
  setTransactionContext(ctx: Transaction): void {
    this.transactionContext = ctx;
  }

  /**
   * Resets the transaction context.
   */
  resetTransactionContext(): void {
    delete this.transactionContext;
  }

  /**
   * Gets the `MetadataStorage`.
   */
  getMetadata(): MetadataStorage;

  /**
   * Gets the `EntityMetadata` instance when provided with the `entityName` parameter.
   */
  getMetadata<Entity extends object>(entityName: EntityName<Entity>): EntityMetadata<Entity>;

  /**
   * Gets the `MetadataStorage` (without parameters) or `EntityMetadata` instance when provided with the `entityName` parameter.
   */
  getMetadata<Entity extends object>(entityName?: EntityName<Entity>): EntityMetadata<Entity> | MetadataStorage {
    if (entityName) {
      entityName = Utils.className(entityName);
      return this.metadata.get(entityName);
    }

    return this.metadata;
  }

  /**
   * Gets the EntityComparator.
   */
  getComparator(): EntityComparator {
    return this.comparator;
  }

  private checkLockRequirements(mode: LockMode | undefined, meta: EntityMetadata): void {
    if (!mode) {
      return;
    }

    if (mode === LockMode.OPTIMISTIC && !meta.versionProperty) {
      throw OptimisticLockError.notVersioned(meta);
    }

    if ([LockMode.PESSIMISTIC_READ, LockMode.PESSIMISTIC_WRITE].includes(mode) && !this.isInTransaction()) {
      throw ValidationError.transactionRequired();
    }
  }

  private async lockAndPopulate<T extends object, P extends string = never, F extends string = '*', E extends string = never>(meta: EntityMetadata<T>, entity: T, where: FilterQuery<T>, options: FindOneOptions<T, P, F, E>): Promise<Loaded<T, P, F, E>> {
    if (!meta.virtual && options.lockMode === LockMode.OPTIMISTIC) {
      await this.lock(entity, options.lockMode, {
        lockVersion: options.lockVersion,
        lockTableAliases: options.lockTableAliases,
      });
    }

    const preparedPopulate = await this.preparePopulate<T>(meta.className, options);
    await this.entityLoader.populate(meta.className, [entity], preparedPopulate, {
      ...options as Dictionary,
      ...this.getPopulateWhere<T>(where as ObjectQuery<T>, options),
      convertCustomTypes: false,
      ignoreLazyScalarProperties: true,
      lookup: false,
    });

    return entity as Loaded<T, P, F, E>;
  }

  private buildFields<T extends object, P extends string>(fields: readonly EntityField<T, P>[]): string[] {
    return fields.reduce((ret, f) => {
      if (Utils.isPlainObject(f)) {
        Utils.keys(f).forEach(ff => ret.push(...this.buildFields(f[ff]!).map(field => `${ff as string}.${field}` as never)));
      } else {
        ret.push(f as never);
      }

      return ret;
    }, [] as string[]);
  }

  private async preparePopulate<Entity extends object>(entityName: string, options: Pick<FindOptions<Entity, any, any>, 'populate' | 'strategy' | 'fields' | 'flags'>, validate = true): Promise<PopulateOptions<Entity>[]> {
    if (options.populate === false) {
      return [];
    }

    const meta = this.metadata.find(entityName)!;

    // infer populate hint if only `fields` are available
    if (!options.populate && options.fields) {
      // we need to prune the `populate` hint from to-one relations, as partially loading them does not require their population, we want just the FK
      const pruneToOneRelations = (meta: EntityMetadata, fields: string[]): string[] => {
        const ret: string[] = [];

        for (const field of fields) {
          if (field === PopulatePath.ALL || field.startsWith(`${PopulatePath.ALL}.`)) {
            ret.push(...meta.props.filter(prop => prop.lazy || [ReferenceKind.SCALAR, ReferenceKind.EMBEDDED].includes(prop.kind)).map(prop => prop.name));
            continue;
          }

          if (!field.includes('.') && ![ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(meta.properties[field].kind)) {
            ret.push(field);
            continue;
          }

          const parts = field.split('.');
          const key = parts.shift()!;

          if (parts.length === 0) {
            continue;
          }

          const prop = meta.properties[key];
          const inner = pruneToOneRelations(prop.targetMeta!, [parts.join('.')]);

          if (inner.length > 0) {
            ret.push(...inner.map(c => `${key}.${c}`));
          }
        }

        return Utils.unique(ret);
      };

      options.populate = pruneToOneRelations(meta, this.buildFields(options.fields)) as any;
    }

    if (!options.populate) {
      const populate = this.entityLoader.normalizePopulate<Entity>(entityName, [], options.strategy as LoadStrategy);
      await this.autoJoinRefsForFilters(meta, { ...options, populate });

      return populate;
    }

    if (typeof options.populate !== 'boolean') {
      options.populate = Utils.asArray(options.populate).map(field => {
        /* istanbul ignore next */
        if (typeof field === 'boolean' || field === PopulatePath.ALL) {
          return [{ field: meta.primaryKeys[0], strategy: options.strategy, all: !!field }]; //
        }

        // will be handled in QueryBuilder when processing the where condition via CriteriaNode
        if (field === PopulatePath.INFER) {
          options.flags ??= [];
          options.flags.push(QueryFlag.INFER_POPULATE);

          return [];
        }

        if (Utils.isString(field)) {
          return [{ field, strategy: options.strategy }];
        }

        return [field];
      }).flat() as any;
    }

    const populate: PopulateOptions<Entity>[] = this.entityLoader.normalizePopulate<Entity>(entityName, options.populate as true, options.strategy as LoadStrategy);
    const invalid = populate.find(({ field }) => !this.canPopulate(entityName, field));

    if (validate && invalid) {
      throw ValidationError.invalidPropertyName(entityName, invalid.field);
    }

    await this.autoJoinRefsForFilters(meta, { ...options, populate });

    return populate.map(field => {
      // force select-in strategy when populating all relations as otherwise we could cause infinite loops when self-referencing
      const all = field.all ?? (Array.isArray(options.populate) && options.populate.includes('*'));
      field.strategy = all ? LoadStrategy.SELECT_IN : (options.strategy ?? field.strategy) as LoadStrategy;

      return field;
    });
  }

  /**
   * when the entity is found in identity map, we check if it was partially loaded or we are trying to populate
   * some additional lazy properties, if so, we reload and merge the data from database
   */
  protected shouldRefresh<T extends object, P extends string = never, F extends string = '*', E extends string = never>(meta: EntityMetadata<T>, entity: T, options: FindOneOptions<T, P, F, E>) {
    if (!helper(entity).__initialized || options.refresh) {
      return true;
    }

    let autoRefresh: boolean;

    if (options.fields) {
      autoRefresh = options.fields.some(field => !helper(entity).__loadedProperties.has(field as string));
    } else {
      autoRefresh = meta.comparableProps.some(prop => {
        const inlineEmbedded = prop.kind === ReferenceKind.EMBEDDED && !prop.object;
        return !inlineEmbedded && !prop.lazy && !helper(entity).__loadedProperties.has(prop.name);
      });
    }

    if (autoRefresh) {
      return true;
    }

    if (Array.isArray(options.populate)) {
      return options.populate.some(field => !helper(entity).__loadedProperties.has(field as string));
    }

    return !!options.populate;
  }

  protected prepareOptions(options: FindOptions<any, any, any, any> | FindOneOptions<any, any, any, any>): void {
    if (!Utils.isEmpty(options.fields) && !Utils.isEmpty(options.exclude)) {
      throw new ValidationError(`Cannot combine 'fields' and 'exclude' option.`);
    }

    options.schema ??= this._schema;
    options.logging = Utils.merge(
      { id: this.id },
      this.loggerContext,
      options.loggerContext,
      options.logging,
    );
  }

  /**
   * @internal
   */
  cacheKey<T extends object>(
    entityName: string,
    options: FindOptions<T, any, any, any> | FindOneOptions<T, any, any, any> | CountOptions<T, any>,
    method: string,
    where: FilterQuery<T>,
  ): unknown[] {
    const { ...opts } = options;

    // ignore some irrelevant options, e.g. logger context can contain dynamic data for the same query
    for (const k of ['ctx', 'strategy', 'flushMode', 'logging', 'loggerContext']) {
      delete opts[k as keyof typeof opts];
    }

    return [entityName, method, opts, where];
  }

  /**
   * @internal
   */
  async tryCache<T extends object, R>(entityName: string, config: boolean | number | [string, number] | undefined, key: unknown, refresh?: boolean, merge?: boolean): Promise<{ data?: R; key: string } | undefined> {
    config ??= this.config.get('resultCache').global;

    if (!config) {
      return undefined;
    }

    const em = this.getContext();
    const cacheKey = Array.isArray(config) ? config[0] : JSON.stringify(key);
    const cached = await em.resultCache.get(cacheKey!);

    if (cached) {
      let data: R;

      if (Array.isArray(cached) && merge) {
        data = cached.map(item => em.entityFactory.create<T>(entityName, item, {
          merge: true,
          convertCustomTypes: true,
          refresh,
          recomputeSnapshot: true,
        })) as unknown as R;
      } else if (Utils.isObject<EntityData<T>>(cached) && merge) {
        data = em.entityFactory.create<T>(entityName, cached, {
          merge: true,
          convertCustomTypes: true,
          refresh,
          recomputeSnapshot: true,
        }) as unknown as R;
      } else {
        data = cached;
      }

      await em.unitOfWork.dispatchOnLoadEvent();

      return { key: cacheKey, data };
    }

    return { key: cacheKey };
  }

  /**
   * @internal
   */
  async storeCache(config: boolean | number | [string, number] | undefined, key: { key: string }, data: unknown | (() => unknown)) {
    config ??= this.config.get('resultCache').global;

    if (config) {
      const em = this.getContext();
      const expiration = Array.isArray(config) ? config[1] : (Utils.isNumber(config) ? config : undefined);
      await em.resultCache.set(key.key, data instanceof Function ? data() : data, '', expiration);
    }
  }

  /**
   * Clears result cache for given cache key. If we want to be able to call this method,
   * we need to set the cache key explicitly when storing the cache.
   *
   * ```ts
   * // set the cache key to 'book-cache-key', with expiration of 60s
   * const res = await em.find(Book, { ... }, { cache: ['book-cache-key', 60_000] });
   *
   * // clear the cache key by name
   * await em.clearCache('book-cache-key');
   * ```
   */
  async clearCache(cacheKey: string) {
    await this.getContext().resultCache.remove(cacheKey);
  }

  /**
   * Returns the default schema of this EntityManager. Respects the context, so global EM will give you the contextual schema
   * if executed inside request context handler.
   */
  get schema(): string | undefined {
    return this.getContext(false)._schema;
  }

  /**
   * Sets the default schema of this EntityManager. Respects the context, so global EM will set the contextual schema
   * if executed inside request context handler.
   */
  set schema(schema: string | null | undefined) {
    this.getContext(false)._schema = schema ?? undefined;
  }

  /**
   * Returns the ID of this EntityManager. Respects the context, so global EM will give you the contextual ID
   * if executed inside request context handler.
   */
  get id(): number {
    return this.getContext(false)._id;
  }

  /** @ignore */
  [inspect.custom]() {
    return `[EntityManager<${this.id}>]`;
  }

}

export interface CreateOptions<Convert extends boolean> {
  /** creates a managed entity instance instead, bypassing the constructor call */
  managed?: boolean;
  /** create entity in a specific schema - alternatively, use `wrap(entity).setSchema()` */
  schema?: string;
  /** persist the entity automatically - this is the default behavior and is also configurable globally via `persistOnCreate` option */
  persist?: boolean;
  /** this option disables the strict typing which requires all mandatory properties to have value, it has no effect on runtime */
  partial?: boolean;
  /** convert raw database values based on mapped types (by default, already converted values are expected) */
  convertCustomTypes?: Convert;
}

export interface MergeOptions {
  refresh?: boolean;
  convertCustomTypes?: boolean;
  schema?: string;
}

export interface ForkOptions {
  /** do we want a clear identity map? defaults to true */
  clear?: boolean;
  /** use request context? should be used only for top level request scope EM, defaults to false */
  useContext?: boolean;
  /** do we want to use fresh EventManager instance? defaults to false (global instance) */
  freshEventManager?: boolean;
  /** do we want to clone current EventManager instance? defaults to false (global instance) */
  cloneEventManager?: boolean;
  /** use this flag to ignore the current async context - this is required if we want to call `em.fork()` inside the `getContext` handler */
  disableContextResolution?: boolean;
  /** set flush mode for this fork, overrides the global option can be overridden locally via FindOptions */
  flushMode?: FlushMode;
  /** disable transactions for this fork */
  disableTransactions?: boolean;
  /** should we keep the transaction context of the parent EM? */
  keepTransactionContext?: boolean;
  /** default schema to use for this fork */
  schema?: string;
  /** default logger context, can be overridden via {@apilink FindOptions} */
  loggerContext?: Dictionary;
}