mikro-orm/mikro-orm

View on GitHub
packages/core/src/entity/EntityLoader.ts

Summary

Maintainability
A
0 mins
Test Coverage
A
100%
import type {
  AnyEntity,
  ConnectionType,
  Dictionary,
  EntityKey,
  EntityMetadata,
  EntityProperty,
  FilterKey,
  FilterQuery,
  PopulateOptions,
  Primary,
} from '../typings';
import type { EntityManager } from '../EntityManager';
import { QueryHelper } from '../utils/QueryHelper';
import { Utils } from '../utils/Utils';
import { ValidationError } from '../errors';
import type { Collection } from './Collection';
import {
  LoadStrategy,
  type LockMode,
  type PopulateHint,
  PopulatePath,
  type QueryOrderMap,
  ReferenceKind,
} from '../enums';
import { Reference, type ScalarReference } from './Reference';
import type { EntityField, FindOptions, IDatabaseDriver } from '../drivers/IDatabaseDriver';
import type { MetadataStorage } from '../metadata/MetadataStorage';
import type { Platform } from '../platforms/Platform';
import { helper } from './wrap';
import type { LoggingOptions } from '../logging/Logger';
import { raw, RawQueryFragment } from '../utils/RawQueryFragment';

export type EntityLoaderOptions<Entity, Fields extends string = PopulatePath.ALL, Excludes extends string = never> = {
  where?: FilterQuery<Entity>;
  populateWhere?: PopulateHint | `${PopulateHint}`;
  fields?: readonly EntityField<Entity, Fields>[];
  exclude?: readonly EntityField<Entity, Excludes>[];
  orderBy?: QueryOrderMap<Entity> | QueryOrderMap<Entity>[];
  refresh?: boolean;
  validate?: boolean;
  lookup?: boolean;
  convertCustomTypes?: boolean;
  ignoreLazyScalarProperties?: boolean;
  filters?: Dictionary<boolean | Dictionary> | string[] | boolean;
  strategy?: LoadStrategy;
  lockMode?: Exclude<LockMode, LockMode.OPTIMISTIC>;
  schema?: string;
  connectionType?: ConnectionType;
  logging?: LoggingOptions;
};

export class EntityLoader {

  private readonly metadata: MetadataStorage;
  private readonly driver: IDatabaseDriver;

  constructor(private readonly em: EntityManager) {
    this.metadata = this.em.getMetadata();
    this.driver = this.em.getDriver();
  }

  /**
   * 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, Fields extends string = PopulatePath.ALL>(entityName: string, entities: Entity[], populate: PopulateOptions<Entity>[] | boolean, options: EntityLoaderOptions<Entity, Fields>): Promise<void> {
    if (entities.length === 0 || Utils.isEmpty(populate)) {
      return this.setSerializationContext(entities, populate, options);
    }

    if ((entities as AnyEntity[]).some(e => !e.__helper)) {
      const entity = entities.find(e => !Utils.isEntity(e));
      const meta = this.metadata.find(entityName)!;
      throw ValidationError.notDiscoveredEntity(entity, meta, 'populate');
    }

    const visited = (options as Dictionary).visited ??= new Set<AnyEntity>();
    options.where ??= {} as FilterQuery<Entity>;
    options.orderBy ??= {};
    options.filters ??= {};
    options.lookup ??= true;
    options.validate ??= true;
    options.refresh ??= false;
    options.convertCustomTypes ??= true;
    populate = this.normalizePopulate<Entity>(entityName, populate as true, options.strategy, options.lookup);
    const invalid = populate.find(({ field }) => !this.em.canPopulate(entityName, field));

    /* istanbul ignore next */
    if (options.validate && invalid) {
      throw ValidationError.invalidPropertyName(entityName, invalid.field);
    }

    this.setSerializationContext(entities, populate, options);

    for (const entity of entities) {
      visited.add(entity);
    }

    for (const pop of populate) {
      await this.populateField<Entity>(entityName, entities, pop, options as Required<EntityLoaderOptions<Entity>>);
    }
  }

  normalizePopulate<Entity>(entityName: string, populate: (PopulateOptions<Entity> | boolean)[] | PopulateOptions<Entity> | boolean, strategy?: LoadStrategy, lookup = true): PopulateOptions<Entity>[] {
    const meta = this.metadata.find(entityName)!;
    let normalized = Utils.asArray(populate).map(field => {
      return typeof field === 'boolean' || field.field === PopulatePath.ALL ? { all: !!field, field: meta.primaryKeys[0] } as PopulateOptions<Entity> : field;
    });

    if (normalized.some(p => p.all)) {
      normalized = this.lookupAllRelationships(entityName);
    }

    // convert nested `field` with dot syntax to PopulateOptions with `children` array
    this.expandDotPaths(normalized, meta);

    if (lookup && populate !== false) {
      normalized = this.lookupEagerLoadedRelationships(entityName, normalized, strategy);

      // convert nested `field` with dot syntax produced by eager relations
      this.expandDotPaths(normalized, meta);
    }

    // merge same fields
    return this.mergeNestedPopulate(normalized);
  }

  private setSerializationContext<Entity extends object, Fields extends string = PopulatePath.ALL>(entities: Entity[], populate: PopulateOptions<Entity>[] | boolean, options: EntityLoaderOptions<Entity, Fields>): void {
    const exclude = options.exclude as string[] ?? [];

    for (const entity of entities) {
      const context = helper(entity).__serializationContext;
      context.populate = context.populate ? context.populate.concat(populate as any) : populate as PopulateOptions<Entity>[];
      context.exclude = context.exclude ? context.exclude.concat(exclude) : exclude;

      if (context.fields && options.fields) {
        options.fields.forEach(f => context.fields!.add(f as string));
      } else if (options.fields) {
        context.fields = new Set(options.fields as string[]);
      } else {
        context.fields = new Set([PopulatePath.ALL]);
      }
    }
  }

  private expandDotPaths<Entity>(normalized: PopulateOptions<Entity>[], meta: EntityMetadata<any>) {
    normalized.forEach(p => {
      if (!p.field.includes('.')) {
        return;
      }

      const [f, ...parts] = p.field.split('.');
      p.field = f as EntityKey<Entity>;
      p.children ??= [];
      const prop = meta.properties[f];
      p.strategy ??= prop.strategy;

      if (parts[0] === PopulatePath.ALL) {
        prop.targetMeta!.props
          .filter(prop => prop.lazy || prop.kind !== ReferenceKind.SCALAR)
          .forEach(prop => p.children!.push({ field: prop.name as EntityKey, strategy: p.strategy }));
      } else {
        p.children.push(this.expandNestedPopulate(prop.type, parts, p.strategy, p.all));
      }
    });
  }

  /**
   * Merge multiple populates for the same entity with different children. Also skips `*` fields, those can come from
   * partial loading hints (`fields`) that are used to infer the `populate` hint if missing.
   */
  private mergeNestedPopulate<Entity>(populate: PopulateOptions<Entity>[]): PopulateOptions<Entity>[] {
    const tmp = populate.reduce((ret, item) => {
      if (item.field === PopulatePath.ALL) {
        return ret;
      }

      if (!ret[item.field]) {
        ret[item.field] = item;
        return ret;
      }

      if (!ret[item.field].children && item.children) {
        ret[item.field].children = item.children;
      } else if (ret[item.field].children && item.children) {
        ret[item.field].children!.push(...item.children!);
      }

      return ret;
    }, {} as Dictionary<PopulateOptions<Entity>>);

    return Object.values(tmp).map(item => {
      if (item.children) {
        item.children = this.mergeNestedPopulate(item.children);
      }

      return item;
    });
  }

  /**
   * Expands `books.perex` like populate to use `children` array instead of the dot syntax
   */
  private expandNestedPopulate<Entity>(entityName: string, parts: string[], strategy?: LoadStrategy, all?: boolean): PopulateOptions<Entity> {
    const meta = this.metadata.find(entityName)!;
    const field = parts.shift()!;
    const prop = meta.properties[field];
    const ret = { field, strategy, all } as PopulateOptions<Entity>;

    if (parts.length > 0) {
      ret.children = [this.expandNestedPopulate(prop.type, parts, strategy)];
    }

    return ret;
  }

  /**
   * preload everything in one call (this will update already existing references in IM)
   */
  private async populateMany<Entity extends object>(entityName: string, entities: Entity[], populate: PopulateOptions<Entity>, options: Required<EntityLoaderOptions<Entity>>): Promise<AnyEntity[]> {
    const [field, ref] = populate.field.split(':', 2) as [EntityKey<Entity>, string | undefined];
    const meta = this.metadata.find<Entity>(entityName)!;
    const prop = meta.properties[field];

    if (prop.kind === ReferenceKind.SCALAR && prop.lazy) {
      const filtered = entities.filter(e => options.refresh || (prop.ref ? !(e[prop.name] as ScalarReference<any>)?.isInitialized() : e[prop.name] === undefined));

      if (options.ignoreLazyScalarProperties || filtered.length === 0) {
        return entities as AnyEntity[];
      }

      const pk = Utils.getPrimaryKeyHash(meta.primaryKeys) as FilterKey<Entity>;
      const ids = Utils.unique(filtered.map(e => Utils.getPrimaryKeyValues(e, meta.primaryKeys, true)));
      const where = this.mergePrimaryCondition<Entity>(ids as Entity[], pk, options, meta, this.metadata, this.driver.getPlatform());
      const { filters, convertCustomTypes, lockMode, strategy, populateWhere, connectionType, logging } = options;

      await this.em.find(meta.className, where, {
        filters, convertCustomTypes, lockMode, strategy, populateWhere, connectionType, logging,
        fields: [prop.name] as never,
        populate: [],
      });

      return entities as AnyEntity[];
    }

    if (prop.kind === ReferenceKind.EMBEDDED) {
      return [];
    }

    const filtered = this.filterCollections<Entity>(entities, field, options, ref);
    const innerOrderBy = Utils.asArray(options.orderBy)
      .filter(orderBy => (Array.isArray(orderBy[prop.name]) && (orderBy[prop.name] as unknown[]).length > 0) || Utils.isObject(orderBy[prop.name]))
      .flatMap(orderBy => orderBy[prop.name]);

    if (prop.kind === ReferenceKind.MANY_TO_MANY && this.driver.getPlatform().usesPivotTable()) {
      return this.findChildrenFromPivotTable<Entity>(filtered, prop, options, innerOrderBy as QueryOrderMap<Entity>[], populate, !!ref);
    }

    const where = await this.extractChildCondition(options, prop);
    const data = await this.findChildren<Entity>(entities, prop, populate, { ...options, where, orderBy: innerOrderBy! }, !!ref);
    this.initializeCollections<Entity>(filtered, prop, field, data, innerOrderBy.length > 0);

    return data;
  }

  private initializeCollections<Entity extends object>(filtered: Entity[], prop: EntityProperty, field: keyof Entity, children: AnyEntity[], customOrder: boolean): void {
    if (prop.kind === ReferenceKind.ONE_TO_MANY) {
      this.initializeOneToMany<Entity>(filtered, children, prop, field);
    }

    if (prop.kind === ReferenceKind.MANY_TO_MANY && !this.driver.getPlatform().usesPivotTable()) {
      this.initializeManyToMany<Entity>(filtered, children, prop, field, customOrder);
    }
  }

  private initializeOneToMany<Entity extends object>(filtered: Entity[], children: AnyEntity[], prop: EntityProperty, field: keyof Entity): void {
    const mapToPk = prop.targetMeta!.properties[prop.mappedBy].mapToPk;
    const map: Dictionary<Entity[]> = {};

    for (const entity of filtered) {
      const key = helper(entity).getSerializedPrimaryKey();
      map[key] = [];
    }

    for (const child of children) {
      const pk = child.__helper.__data[prop.mappedBy] ?? child[prop.mappedBy];

      if (pk) {
        const key = helper(mapToPk ? this.em.getReference(prop.type, pk) : pk).getSerializedPrimaryKey();
        map[key]?.push(child as Entity);
      }
    }

    for (const entity of filtered) {
      const key = helper(entity).getSerializedPrimaryKey();
      (entity[field] as unknown as Collection<Entity>).hydrate(map[key]);
    }
  }

  private initializeManyToMany<Entity>(filtered: Entity[], children: AnyEntity[], prop: EntityProperty<Entity>, field: keyof Entity, customOrder: boolean): void {
    if (prop.mappedBy) {
      for (const entity of filtered) {
        const items = children.filter(child => (child[prop.mappedBy] as Collection<AnyEntity>).contains(entity as AnyEntity, false));
        (entity[field] as Collection<AnyEntity>).hydrate(items, true);
      }
    } else { // owning side of M:N without pivot table needs to be reordered
      for (const entity of filtered) {
        const order = !customOrder ? [...(entity[prop.name] as Collection<AnyEntity>).getItems(false)] : []; // copy order of references
        const items = children.filter(child => (entity[prop.name] as Collection<AnyEntity>).contains(child, false));

        if (!customOrder) {
          items.sort((a, b) => order.indexOf(a) - order.indexOf(b));
        }

        (entity[field] as Collection<AnyEntity>).hydrate(items, true);
      }
    }
  }

  private async findChildren<Entity extends object>(entities: Entity[], prop: EntityProperty<Entity>, populate: PopulateOptions<Entity>, options: Required<EntityLoaderOptions<Entity>>, ref: boolean): Promise<AnyEntity[]> {
    const children = this.getChildReferences<Entity>(entities, prop, options, ref);
    const meta = prop.targetMeta!;
    let fk = Utils.getPrimaryKeyHash(meta.primaryKeys);
    let schema: string | undefined = options.schema;

    if (prop.kind === ReferenceKind.ONE_TO_MANY || (prop.kind === ReferenceKind.MANY_TO_MANY && !prop.owner)) {
      fk = meta.properties[prop.mappedBy].name;
    }

    if (prop.kind === ReferenceKind.ONE_TO_ONE && !prop.owner) {
      children.length = 0;
      fk = meta.properties[prop.mappedBy].name;
      children.push(...this.filterByReferences(entities, prop.name, options.refresh) as AnyEntity[]);
    }

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

    if (!schema && [ReferenceKind.ONE_TO_ONE, ReferenceKind.MANY_TO_ONE].includes(prop.kind)) {
      schema = children.find(e => e.__helper!.__schema)?.__helper!.__schema;
    }

    const ids = Utils.unique(children.map(e => e.__helper.getPrimaryKey()));
    let where = this.mergePrimaryCondition<Entity>(ids, fk as FilterKey<Entity>, options, meta, this.metadata, this.driver.getPlatform());
    const fields = this.buildFields(options.fields, prop, ref) as any;

    /* eslint-disable prefer-const */
    let {
      refresh,
      filters,
      convertCustomTypes,
      lockMode,
      strategy,
      populateWhere,
      connectionType,
      logging,
    } = options;
    /* eslint-enable prefer-const */

    if (typeof populateWhere === 'object') {
      populateWhere = await this.extractChildCondition({ where: populateWhere } as any, prop);
    }

    if (!Utils.isEmpty(prop.where)) {
      where = { $and: [where, prop.where] } as FilterQuery<Entity>;
    }

    const propOrderBy: QueryOrderMap<Entity>[] = [];

    if (prop.orderBy) {
      for (const item of Utils.asArray(prop.orderBy)) {
        for (const field of Utils.keys(item)) {
          const rawField = RawQueryFragment.getKnownFragment(field, false);

          if (rawField) {
            const raw2 = raw(rawField.sql, rawField.params);
            propOrderBy.push({ [raw2.toString()]: item[field] } as QueryOrderMap<Entity>);
            continue;
          }

          propOrderBy.push({ [field]: item[field] } as QueryOrderMap<Entity>);
        }
      }
    }

    const items = await this.em.find(prop.type, where, {
      filters, convertCustomTypes, lockMode, populateWhere, logging,
      orderBy: [...Utils.asArray(options.orderBy), ...propOrderBy] as QueryOrderMap<Entity>[],
      populate: populate.children as never ?? populate.all ?? [],
      exclude: Array.isArray(options.exclude) ? Utils.extractChildElements(options.exclude, prop.name) as any : options.exclude,
      strategy, fields, schema, connectionType,
      // @ts-ignore not a public option, will be propagated to the populate call
      refresh: refresh && !children.every(item => options.visited.has(item)),
      // @ts-ignore not a public option, will be propagated to the populate call
      visited: options.visited,
    });

    for (const item of items) {
      if (ref && !helper(item).__onLoadFired) {
        helper(item).__initialized = false;
        // eslint-disable-next-line dot-notation
        this.em.getUnitOfWork()['loadedEntities'].delete(item);
      }
    }

    return items;
  }

  private mergePrimaryCondition<Entity>(ids: Entity[], pk: FilterKey<Entity>, options: EntityLoaderOptions<Entity>, meta: EntityMetadata, metadata: MetadataStorage, platform: Platform): FilterQuery<Entity> {
    const cond1 = QueryHelper.processWhere({ where: { [pk]: { $in: ids } }, entityName: meta.className, metadata, platform, convertCustomTypes: !options.convertCustomTypes });

    return options.where![pk]
      ? { $and: [cond1, options.where] } as FilterQuery<any>
      : { ...cond1, ...(options.where as Dictionary) };
  }

  private async populateField<Entity extends object>(entityName: string, entities: Entity[], populate: PopulateOptions<Entity>, options: Required<EntityLoaderOptions<Entity>>): Promise<void> {
    const field = populate.field.split(':')[0] as EntityKey<Entity>;
    const prop = this.metadata.find(entityName)!.properties[field] as EntityProperty<Entity>;

    if (prop.kind === ReferenceKind.SCALAR && !prop.lazy) {
      return;
    }

    const populated = await this.populateMany<Entity>(entityName, entities, populate, options);

    if (!populate.children && !populate.all) {
      return;
    }

    const children: Entity[] = [];

    for (const entity of entities) {
      const ref = entity[field] as unknown;

      if (Utils.isEntity<Entity>(ref)) {
        children.push(ref);
      } else if (Reference.isReference<Entity>(ref)) {
        children.push(ref.unwrap());
      } else if (Utils.isCollection<Entity>(ref)) {
        children.push(...ref.getItems());
      } else if (ref && prop.kind === ReferenceKind.EMBEDDED) {
        children.push(...Utils.asArray(ref as Entity));
      }
    }

    if (populated.length === 0 && !populate.children) {
      return;
    }

    const fields = this.buildFields(options.fields, prop);
    const innerOrderBy = Utils.asArray(options.orderBy)
      .filter(orderBy => Utils.isObject(orderBy[prop.name]))
      .map(orderBy => orderBy[prop.name]);
    const { refresh, filters, ignoreLazyScalarProperties, populateWhere, connectionType, logging } = options;
    const exclude = Array.isArray(options.exclude) ? Utils.extractChildElements(options.exclude, prop.name) as any : options.exclude;
    const filtered = Utils.unique(children.filter(e => !(options as Dictionary).visited.has(e)));

    await this.populate<Entity>(prop.type, filtered, populate.children ?? populate.all as any, {
      where: await this.extractChildCondition(options, prop, false) as FilterQuery<Entity>,
      orderBy: innerOrderBy as QueryOrderMap<Entity>[],
      fields,
      exclude,
      validate: false,
      lookup: false,
      filters,
      ignoreLazyScalarProperties,
      populateWhere,
      connectionType,
      logging,
      // @ts-ignore not a public option, will be propagated to the populate call
      refresh: refresh && !filtered.every(item => options.visited.has(item)),
      // @ts-ignore not a public option, will be propagated to the populate call
      visited: options.visited,
    });
  }

  private async findChildrenFromPivotTable<Entity extends object>(filtered: Entity[], prop: EntityProperty<Entity>, options: Required<EntityLoaderOptions<Entity>>, orderBy?: QueryOrderMap<Entity>[], populate?: PopulateOptions<Entity>, pivotJoin?: boolean): Promise<AnyEntity[]> {
    const ids = (filtered as AnyEntity[]).map(e => e.__helper!.__primaryKeys);
    const refresh = options.refresh;
    let where = await this.extractChildCondition(options, prop, true);
    const fields = this.buildFields(options.fields, prop);
    const exclude = Array.isArray(options.exclude) ? Utils.extractChildElements(options.exclude, prop.name) : options.exclude;
    const options2 = { ...options } as unknown as FindOptions<Entity, any, any, any>;
    delete options2.limit;
    delete options2.offset;
    options2.fields = fields;
    options2.exclude = exclude;
    options2.populate = (populate?.children ?? []);

    if (prop.customType) {
      ids.forEach((id, idx) => ids[idx] = QueryHelper.processCustomType<Entity>(prop, id as FilterQuery<Entity>, this.driver.getPlatform()) as Primary<Entity>[]);
    }

    if (!Utils.isEmpty(prop.where)) {
      where = { $and: [where, prop.where] } as FilterQuery<Entity>;
    }

    const map = await this.driver.loadFromPivotTable<any, any>(prop, ids, where, orderBy, this.em.getTransactionContext(), options2, pivotJoin);
    const children: AnyEntity[] = [];

    for (const entity of (filtered as AnyEntity[])) {
      const items = map[entity.__helper!.getSerializedPrimaryKey()].map(item => {
        if (pivotJoin) {
          return this.em.getReference(prop.type, item, {
            convertCustomTypes: true,
            schema: options.schema ?? this.em.config.get('schema'),
          });
        }

        const entity = this.em.getEntityFactory().create(prop.type, item, {
          refresh,
          merge: true,
          convertCustomTypes: true,
          schema: options.schema ?? this.em.config.get('schema'),
        });
        return this.em.getUnitOfWork().register(entity as AnyEntity, item, { refresh, loaded: true });
      });
      (entity[prop.name] as unknown as Collection<AnyEntity>).hydrate(items, true);
      children.push(...items);
    }

    return children;
  }

  private async extractChildCondition<Entity>(options: Required<EntityLoaderOptions<Entity>>, prop: EntityProperty<Entity>, filters = false) {
    const where = options.where as Dictionary;
    const subCond = Utils.isPlainObject(where[prop.name]) ? where[prop.name] : {};
    const meta2 = this.metadata.find(prop.type)!;
    const pk = Utils.getPrimaryKeyHash(meta2.primaryKeys);

    ['$and', '$or'].forEach(op => {
      if (where[op]) {
        const child = where[op]
          .map((cond: Dictionary) => cond[prop.name])
          .filter((sub: unknown) => sub != null && !(Utils.isPlainObject(sub) && Object.keys(sub).every(key => Utils.isOperator(key, false))))
          .map((cond: Dictionary) => {
            if (Utils.isPrimaryKey(cond)) {
              return { [pk]: cond };
            }

            return cond;
          });

        if (child.length > 0) {
          subCond[op] = child;
        }
      }
    });

    const operators = Object.keys(subCond).filter(key => Utils.isOperator(key, false));

    if (operators.length > 0) {
      operators.forEach(op => {
        subCond[pk] ??= {};
        subCond[pk][op] = subCond[op];
        delete subCond[op];
      });
    }

    if (filters) {
      return this.em.applyFilters(prop.type, subCond, options.filters, 'read', options);
    }

    return subCond;
  }

  private buildFields<Entity>(fields: readonly EntityField<Entity>[] = [], prop: EntityProperty<Entity>, ref?: boolean): readonly EntityField<Entity>[] | undefined {
    if (ref) {
      fields = prop.targetMeta!.primaryKeys.map(targetPkName => `${prop.name}.${targetPkName}`) as EntityField<Entity>[];
    }

    const ret = fields.reduce((ret, f) => {
      if (Utils.isPlainObject(f)) {
        Utils.keys(f)
          .filter(ff => ff === prop.name)
          .forEach(ff => ret.push(...f[ff] as EntityField<Entity>[]));
      } else if (f.toString().includes('.')) {
        const parts = f.toString().split('.');
        const propName = parts.shift();
        const childPropName = parts.join('.') as EntityField<Entity>;

        /* istanbul ignore else */
        if (propName === prop.name) {
          ret.push(childPropName);
        }
      }

      return ret;
    }, [] as EntityField<Entity>[]);

    if (ret.length === 0) {
      return undefined;
    }

    // we need to automatically select the FKs too, e.g. for 1:m relations to be able to wire them with the items
    if (prop.kind === ReferenceKind.ONE_TO_MANY || prop.kind === ReferenceKind.MANY_TO_MANY) {
      const owner = prop.targetMeta!.properties[prop.mappedBy] as EntityProperty<Entity>;

      if (owner && !ret.includes(owner.name)) {
        ret.push(owner.name);
      }
    }

    return ret;
  }

  private getChildReferences<Entity extends object>(entities: Entity[], prop: EntityProperty<Entity>, options: Required<EntityLoaderOptions<Entity>>, ref: boolean): AnyEntity[] {
    const filtered = this.filterCollections(entities, prop.name, options, ref);
    const children: AnyEntity[] = [];

    if (prop.kind === ReferenceKind.ONE_TO_MANY) {
      children.push(...filtered.map(e => (e[prop.name] as unknown as Collection<Entity, AnyEntity>).owner));
    } else if (prop.kind === ReferenceKind.MANY_TO_MANY && prop.owner) {
      children.push(...filtered.reduce((a, b) => {
        a.push(...(b[prop.name] as Collection<AnyEntity>).getItems());
        return a;
      }, [] as AnyEntity[]));
    } else if (prop.kind === ReferenceKind.MANY_TO_MANY) { // inverse side
      children.push(...filtered as AnyEntity[]);
    } else { // MANY_TO_ONE or ONE_TO_ONE
      children.push(...this.filterReferences(entities, prop.name, options, ref) as AnyEntity[]);
    }

    return children;
  }

  private filterCollections<Entity extends object>(entities: Entity[], field: keyof Entity, options: Required<EntityLoaderOptions<Entity>>, ref?: string | boolean): Entity[] {
    if (options.refresh) {
      return entities.filter(e => e[field]);
    }

    return entities.filter(e => Utils.isCollection(e[field]) && !(e[field] as unknown as Collection<AnyEntity>).isInitialized(!ref));
  }

  private filterReferences<Entity extends object>(entities: Entity[], field: keyof Entity & string, options: Required<EntityLoaderOptions<Entity>>, ref: boolean): Entity[keyof Entity][] {
    if (ref) {
      return [];
    }

    const children = entities.filter(e => Utils.isEntity(e[field], true));

    if (options.refresh) {
      return children.map(e => Reference.unwrapReference(e[field] as AnyEntity)) as Entity[keyof Entity][];
    }

    if (options.fields) {
      return children
        .filter(e => {
          const wrapped = helper(e[field] as object);

          const childFields = (options.fields as string[])
            .filter(f => f.startsWith(`${field}.`))
            .map(f => f.substring(field.length + 1));

          return !wrapped.__initialized || !childFields.every(field => wrapped.__loadedProperties.has(field));
        })
        .map(e => Reference.unwrapReference(e[field] as AnyEntity)) as Entity[keyof Entity][];
    }

    return children
      .filter(e => !(e[field] as AnyEntity).__helper!.__initialized)
      .map(e => Reference.unwrapReference(e[field] as AnyEntity)) as Entity[keyof Entity][];
  }

  private filterByReferences<Entity extends object>(entities: Entity[], field: keyof Entity, refresh: boolean): Entity[] {
    /* istanbul ignore next */
    if (refresh) {
      return entities;
    }

    return entities.filter(e => !(e[field] as AnyEntity)?.__helper?.__initialized);
  }

  private lookupAllRelationships<Entity>(entityName: string): PopulateOptions<Entity>[] {
    const ret: PopulateOptions<Entity>[] = [];
    const meta = this.metadata.find<Entity>(entityName)!;

    meta.relations.forEach(prop => {
      ret.push({
        field: this.getRelationName(meta, prop),
        // force select-in strategy when populating all relations as otherwise we could cause infinite loops when self-referencing
        strategy: LoadStrategy.SELECT_IN,
        // no need to look up populate children recursively as we just pass `all: true` here
        all: true,
      });
    });

    return ret;
  }

  private getRelationName<Entity>(meta: EntityMetadata<Entity>, prop: EntityProperty<Entity>): EntityKey<Entity> {
    if (!prop.embedded) {
      return prop.name;
    }

    return `${this.getRelationName(meta, meta.properties[prop.embedded[0]])}.${prop.embedded[1]}` as EntityKey<Entity>;
  }

  private lookupEagerLoadedRelationships<Entity>(entityName: string, populate: PopulateOptions<Entity>[], strategy?: LoadStrategy, prefix = '', visited: string[] = []): PopulateOptions<Entity>[] {
    const meta = this.metadata.find<Entity>(entityName);

    if (!meta && !prefix) {
      return populate;
    }

    if (visited.includes(entityName) || !meta) {
      return [];
    }

    visited.push(entityName);
    const ret: PopulateOptions<Entity>[] = prefix === '' ? [...populate] : [];

    meta.relations
      .filter(prop => {
        const eager = prop.eager && !populate.some(p => p.field === `${prop.name}:ref`);
        const populated = populate.some(p => p.field === prop.name);
        const disabled = populate.some(p => p.field === prop.name && p.all === false);

        return !disabled && (eager || populated);
      })
      .forEach(prop => {
        const field = this.getRelationName(meta, prop);
        const prefixed = prefix ? `${prefix}.${field}` as EntityKey<Entity> : field;
        const nestedPopulate = populate.filter(p => p.field === prop.name).flatMap(p => p.children).filter(Boolean);
        const nested = this.lookupEagerLoadedRelationships<Entity>(prop.type, nestedPopulate as any, strategy, prefixed, visited.slice());

        if (nested.length > 0) {
          ret.push(...nested);
        } else {
          const selfReferencing = [meta.className, meta.root.className, ...visited].includes(prop.type) && prop.eager;
          ret.push({
            field: prefixed,
            // enforce select-in strategy for self-referencing relations
            strategy: selfReferencing ? LoadStrategy.SELECT_IN : strategy ?? prop.strategy,
          });
        }
      });

    return ret;
  }

}