mikro-orm-find-dataloader
Version: 
Additional dataloaders for the MikroORM EntityManager find/findOne/etc methods.
235 lines (218 loc) • 9.01 kB
text/typescript
/* eslint-disable @typescript-eslint/dot-notation */
/* eslint-disable @typescript-eslint/ban-types */
/* eslint-disable @typescript-eslint/array-type */
import {
  type EntityManager,
  type AnyEntity,
  type Primary,
  type FilterQuery,
  type FindOptions,
  Utils,
  EntityRepository,
  type EntityName,
  type EntityKey,
  type Loaded,
  type EntityProps,
  type ExpandProperty,
  type ExpandScalar,
  type FilterItemValue,
  type ExpandQuery,
  type Scalar,
} from "@mikro-orm/core";
import DataLoader from "dataloader";
import { type DataloaderFind, groupFindQueries, assertHasNewFilterAndMapKey } from "./findDataloader";
export interface OperatorMapDataloader<T> {
  // $and?: ExpandQuery<T>[];
  $or?: Array<ExpandQuery<T>>;
  // $eq?: ExpandScalar<T> | ExpandScalar<T>[];
  // $ne?: ExpandScalar<T>;
  // $in?: ExpandScalar<T>[];
  // $nin?: ExpandScalar<T>[];
  // $not?: ExpandQuery<T>;
  // $gt?: ExpandScalar<T>;
  // $gte?: ExpandScalar<T>;
  // $lt?: ExpandScalar<T>;
  // $lte?: ExpandScalar<T>;
  // $like?: string;
  // $re?: string;
  // $ilike?: string;
  // $fulltext?: string;
  // $overlap?: string[];
  // $contains?: string[];
  // $contained?: string[];
  // $exists?: boolean;
}
export type FilterValueDataloader<T> =
  /* OperatorMapDataloader<FilterItemValue<T>> | */
  FilterItemValue<T> | FilterItemValue<T>[] | null;
export type QueryDataloader<T> = T extends object
  ? T extends Scalar
    ? never
    : FilterQueryDataloader<T>
  : FilterValueDataloader<T>;
export type FilterObjectDataloader<T> = {
  -readonly [K in EntityKey<T>]?:
    | QueryDataloader<ExpandProperty<T[K]>>
    | FilterValueDataloader<ExpandProperty<T[K]>>
    | null;
};
export type Compute<T> = {
  [K in keyof T]: T[K];
} & {};
export type ObjectQueryDataloader<T> = Compute<OperatorMapDataloader<T> & FilterObjectDataloader<T>>;
// FilterQuery<T>
export type FilterQueryDataloader<T extends object> =
  | ObjectQueryDataloader<T>
  | NonNullable<ExpandScalar<Primary<T>>> // Just 5 (or [5, 7] for composite keys). Currently not supported, we do {id: number} instead. Should be easy to add.
  // Accepts {id: 5} or any scalar like {name: "abc"}, IdentifiedReference (because it extends {id: 5}) but not just 5 nor {location: IdentifiedReference} (don't know why).
  // OperatorMap must be cut down to just a couple.
  | NonNullable<EntityProps<T> & OperatorMapDataloader<T>>
  | FilterQueryDataloader<T>[];
export class EntityDataLoader<T extends AnyEntity<T> = any, P extends string = never, F extends string = never> {
  private readonly bypass: boolean;
  private readonly findLoader: DataLoader<
    Omit<DataloaderFind<T, P, F>, "filtersAndKeys">,
    Array<Loaded<T, P, F>> | Loaded<T, P, F> | null
  >;
  constructor(
    private readonly em: EntityManager,
    bypass: boolean = false,
  ) {
    this.bypass = bypass;
    this.findLoader = new DataLoader<
      Omit<DataloaderFind<T, P, F>, "filtersAndKeys">,
      Array<Loaded<T, P, F>> | Loaded<T, P, F> | null
    >(async (dataloaderFinds) => {
      const queriesMap = groupFindQueries(dataloaderFinds);
      assertHasNewFilterAndMapKey(dataloaderFinds);
      const resultsMap = new Map<string, any[] | Error>();
      await Promise.all(
        Array.from(queriesMap, async ([key, [filter, options]]): Promise<void> => {
          const entityName = key.substring(0, key.indexOf("|"));
          let entitiesOrError: any[] | Error;
          const findOptions = {
            ...(options?.populate != null && {
              populate: options.populate === true ? ["*"] : Array.from(options.populate),
            }),
          } satisfies Pick<FindOptions<any, any>, "populate">;
          try {
            entitiesOrError = await em.getRepository(entityName).find(filter, findOptions);
          } catch (e) {
            entitiesOrError = e as Error;
          }
          resultsMap.set(key, entitiesOrError);
        }),
      );
      return dataloaderFinds.map(({ filtersAndKeys, many }) => {
        const res = filtersAndKeys.reduce<any[]>((acc, { key, newFilter }) => {
          const entitiesOrError = resultsMap.get(key);
          if (entitiesOrError == null) {
            throw new Error("Cannot match results");
          }
          if (!(entitiesOrError instanceof Error)) {
            const res = entitiesOrError[many ? "filter" : "find"]((entity) => {
              return filterResult(entity, newFilter);
            });
            acc.push(...(Array.isArray(res) ? res : [res]));
            return acc;
          } else {
            throw entitiesOrError;
          }
        }, []);
        return many ? res : res[0] ?? null;
      });
      function filterResult<K extends object>(entity: K, filter: FilterQueryDataloader<K>): boolean {
        for (const [key, value] of Object.entries(filter)) {
          const entityValue = entity[key as keyof K];
          if (Array.isArray(value)) {
            if (Array.isArray(entityValue)) {
              // Collection
              if (!value.every((el) => entityValue.includes(el))) {
                return false;
              }
            } else {
              // Single value
              if (!value.includes(entityValue)) {
                return false;
              }
            }
          } else {
            // Object: recursion
            if (!filterResult(entityValue as object, value)) {
              return false;
            }
          }
        }
        return true;
      }
    });
  }
  async find<K extends object, Hint extends string = never, Fields extends string = never>(
    repoOrClass: EntityRepository<K> | EntityName<K>,
    filter: FilterQueryDataloader<K>,
    options?: Pick<FindOptions<K, Hint, Fields>, "populate"> & { bypass?: boolean },
  ): Promise<Array<Loaded<K, Hint, Fields>>> {
    // Property 'entityName' is protected and only accessible within class 'EntityRepository<Entity>' and its subclasses.
    const entityName = Utils.className(
      repoOrClass instanceof EntityRepository ? repoOrClass["entityName"] : repoOrClass,
    );
    return options?.bypass ?? this.bypass
      ? await (repoOrClass instanceof EntityRepository
          ? repoOrClass.find(filter as FilterQuery<K>, options)
          : this.em.find(repoOrClass, filter as FilterQuery<K>, options))
      : await (this.findLoader.load({
          entityName,
          meta: this.em.getMetadata().get(entityName),
          filter: filter as FilterQueryDataloader<T>,
          options: options as Pick<FindOptions<T, P, F>, "populate">,
          many: true,
        }) as unknown as Promise<Array<Loaded<K, Hint, Fields>>>);
  }
  async findOne<K extends object, Hint extends string = never, Fields extends string = never>(
    repoOrClass: EntityRepository<K> | EntityName<K>,
    filter: FilterQueryDataloader<K>,
    options?: Pick<FindOptions<K, Hint, Fields>, "populate"> & { bypass?: boolean },
  ): Promise<Loaded<K, Hint, Fields> | null> {
    // Property 'entityName' is protected and only accessible within class 'EntityRepository<Entity>' and its subclasses.
    const entityName = Utils.className(
      repoOrClass instanceof EntityRepository ? repoOrClass["entityName"] : repoOrClass,
    );
    return options?.bypass ?? this.bypass
      ? await (repoOrClass instanceof EntityRepository
          ? repoOrClass.findOne(filter as FilterQuery<K>, options)
          : this.em.findOne(repoOrClass, filter as FilterQuery<K>, options))
      : await (this.findLoader.load({
          entityName,
          meta: this.em.getMetadata().get(entityName),
          filter: filter as FilterQueryDataloader<T>,
          options: options as Pick<FindOptions<T, P, F>, "populate">,
          many: false,
        }) as unknown as Promise<Loaded<K, Hint, Fields> | null>);
  }
  async findOneOrFail<K extends object, Hint extends string = never, Fields extends string = never>(
    repoOrClass: EntityRepository<K> | EntityName<K>,
    filter: FilterQueryDataloader<K>,
    options?: Pick<FindOptions<K, Hint, Fields>, "populate"> & { bypass?: boolean },
  ): Promise<Loaded<K, Hint, Fields>> {
    // Property 'entityName' is protected and only accessible within class 'EntityRepository<Entity>' and its subclasses.
    const entityName = Utils.className(
      repoOrClass instanceof EntityRepository ? repoOrClass["entityName"] : repoOrClass,
    );
    if (options?.bypass ?? this.bypass) {
      return await (repoOrClass instanceof EntityRepository
        ? repoOrClass.findOneOrFail(filter as FilterQuery<K>, options)
        : this.em.findOneOrFail(repoOrClass, filter as FilterQuery<K>, options));
    }
    const one = (await this.findLoader.load({
      entityName,
      meta: this.em.getMetadata().get(entityName),
      filter: filter as FilterQueryDataloader<T>,
      options: options as Pick<FindOptions<T, P, F>, "populate">,
      many: false,
    })) as unknown as Loaded<K, Hint, Fields> | null;
    if (one == null) {
      throw new Error("Cannot find result");
    }
    return one;
  }
}