diff --git a/.changeset/chilly-pumpkins-reply.md b/.changeset/chilly-pumpkins-reply.md new file mode 100644 index 0000000..4cd4fff --- /dev/null +++ b/.changeset/chilly-pumpkins-reply.md @@ -0,0 +1,6 @@ +--- +"graphql-example": major +"mikro-orm-find-dataloader": major +--- + +Switch to Repository API diff --git a/examples/graphql/src/entities/Author.ts b/examples/graphql/src/entities/Author.ts index 7d927dd..e9e4edf 100644 --- a/examples/graphql/src/entities/Author.ts +++ b/examples/graphql/src/entities/Author.ts @@ -1,6 +1,8 @@ -import { Collection, Entity, ManyToMany, OneToMany, PrimaryKey, Property } from "@mikro-orm/core"; +import { Collection, Entity, EntityRepositoryType, ManyToMany, OneToMany, PrimaryKey, Property } from "@mikro-orm/core"; import { Book } from "./Book"; import { Chat } from "./Chat"; +import { type IFindDataloaderEntityRepository } from "mikro-orm-find-dataloader"; +import { type findDataloaderDefault } from "../mikro-orm-config"; @Entity() export class Author { @@ -30,6 +32,8 @@ export class Author { @OneToMany(() => Chat, (chat) => chat.owner) ownedChats: Collection = new Collection(this); + [EntityRepositoryType]?: IFindDataloaderEntityRepository; + constructor({ id, name, email }: { id?: number; name: string; email: string }) { if (id != null) { this.id = id; diff --git a/examples/graphql/src/entities/Book.ts b/examples/graphql/src/entities/Book.ts index 319d3ef..0af343e 100644 --- a/examples/graphql/src/entities/Book.ts +++ b/examples/graphql/src/entities/Book.ts @@ -1,6 +1,8 @@ -import { Entity, ManyToOne, PrimaryKey, Property, Ref, ref } from "@mikro-orm/core"; +import { Entity, EntityRepositoryType, ManyToOne, PrimaryKey, Property, Ref, ref } from "@mikro-orm/core"; import { Author } from "./Author"; import { Publisher } from "./Publisher"; +import { type IFindDataloaderEntityRepository } from "mikro-orm-find-dataloader"; +import { type findDataloaderDefault } from "../mikro-orm-config"; @Entity() export class Book { @@ -16,6 +18,8 @@ export class Book { @ManyToOne(() => Publisher, { ref: true, nullable: true }) publisher!: Ref | null; + [EntityRepositoryType]?: IFindDataloaderEntityRepository; + constructor({ id, title, author }: { id?: number; title: string; author: Author | Ref }) { if (id != null) { this.id = id; diff --git a/examples/graphql/src/entities/Chat.ts b/examples/graphql/src/entities/Chat.ts index 70020ec..8e86c4d 100644 --- a/examples/graphql/src/entities/Chat.ts +++ b/examples/graphql/src/entities/Chat.ts @@ -1,6 +1,17 @@ -import { Collection, Entity, ManyToOne, OneToMany, PrimaryKeyProp, Ref, ref } from "@mikro-orm/core"; +import { + Collection, + Entity, + EntityRepositoryType, + ManyToOne, + OneToMany, + PrimaryKeyProp, + Ref, + ref, +} from "@mikro-orm/core"; import { Author } from "./Author"; import { Message } from "./Message"; +import { type IFindDataloaderEntityRepository } from "mikro-orm-find-dataloader"; +import { type findDataloaderDefault } from "../mikro-orm-config"; @Entity() export class Chat { @@ -15,6 +26,8 @@ export class Chat { @OneToMany(() => Message, (message) => message.chat) messages: Collection = new Collection(this); + [EntityRepositoryType]?: IFindDataloaderEntityRepository; + constructor({ owner, recipient }: { owner: Author | Ref; recipient: Author | Ref }) { this.owner = ref(owner); this.recipient = ref(recipient); diff --git a/examples/graphql/src/entities/Message.ts b/examples/graphql/src/entities/Message.ts index 07943d9..a48708d 100644 --- a/examples/graphql/src/entities/Message.ts +++ b/examples/graphql/src/entities/Message.ts @@ -1,5 +1,7 @@ -import { Entity, ManyToOne, PrimaryKey, Property, Ref, ref } from "@mikro-orm/core"; +import { Entity, EntityRepositoryType, ManyToOne, PrimaryKey, Property, Ref, ref } from "@mikro-orm/core"; import { Chat } from "./Chat"; +import { type IFindDataloaderEntityRepository } from "mikro-orm-find-dataloader"; +import { type findDataloaderDefault } from "../mikro-orm-config"; @Entity() export class Message { @@ -12,6 +14,8 @@ export class Message { @Property() content: string; + [EntityRepositoryType]?: IFindDataloaderEntityRepository; + constructor({ id, chat, content }: { id?: number; chat?: Chat | Ref; content: string }) { if (id != null) { this.id = id; diff --git a/examples/graphql/src/entities/Publisher.ts b/examples/graphql/src/entities/Publisher.ts index 0390f93..7939ef5 100644 --- a/examples/graphql/src/entities/Publisher.ts +++ b/examples/graphql/src/entities/Publisher.ts @@ -1,5 +1,7 @@ -import { Collection, Entity, Enum, OneToMany, PrimaryKey, Property } from "@mikro-orm/core"; +import { Collection, Entity, EntityRepositoryType, Enum, OneToMany, PrimaryKey, Property } from "@mikro-orm/core"; import { Book } from "./Book"; +import { type IFindDataloaderEntityRepository } from "mikro-orm-find-dataloader"; +import { type findDataloaderDefault } from "../mikro-orm-config"; export enum PublisherType { LOCAL = "local", @@ -20,6 +22,8 @@ export class Publisher { @Enum(() => PublisherType) type = PublisherType.LOCAL; + [EntityRepositoryType]?: IFindDataloaderEntityRepository; + constructor({ id, name = "asd", type = PublisherType.LOCAL }: { id?: number; name?: string; type?: PublisherType }) { if (id != null) { this.id = id; diff --git a/examples/graphql/src/index.ts b/examples/graphql/src/index.ts index e7b1e31..e2221e7 100755 --- a/examples/graphql/src/index.ts +++ b/examples/graphql/src/index.ts @@ -7,7 +7,7 @@ import { assertSingleValue, executeOperation } from "./utils/yoga"; import gql from "graphql-tag"; import { Book } from "./entities/Book"; import { Author } from "./entities/Author"; -import { EntityDataLoader } from "mikro-orm-find-dataloader"; +// import { EntityDataLoader } from "mikro-orm-find-dataloader"; import { type EntityManager } from "@mikro-orm/core"; const getAuthorsQuery = gql` @@ -41,7 +41,7 @@ void (async () => { await populateDatabase(em); em = orm.em.fork(); - const entityDataLoader = new EntityDataLoader(em); + // const entityDataLoader = new EntityDataLoader(em); const schema = createSchema({ typeDefs: gql` @@ -68,12 +68,22 @@ void (async () => { // return await author.books.load(); // return await author.books.load({ dataloader: true }); // return await em.find(Book, { author: author.id }); - return await entityDataLoader.find(Book, { author: author.id }); + // return await entityDataLoader.find(Book, { author: author.id }); + return await em.getRepository(Book).find({ author: author.id }, { dataloader: true }); }, }, }, }); + /* + await em.getRepository(Book).find({}, { populate: ["*"], limit: 2 }); + await em.getRepository(Book).find({}, { populate: ["*"] }); + await em.getRepository(Book).find({}, { populate: ["*"], limit: 2, dataloader: false }); + await em.getRepository(Book).find({}, { populate: ["*"], dataloader: false }); + await em.getRepository(Book).find({}, { populate: ["*"], limit: 2, dataloader: true }); + await em.getRepository(Book).find({}, { populate: ["*"], dataloader: true }); + */ + const yoga = createYoga({ schema }); const res = await executeOperation(yoga, getAuthorsQuery); assertSingleValue(res); diff --git a/examples/graphql/src/mikro-orm-config.ts b/examples/graphql/src/mikro-orm-config.ts index a9e550c..7ab5197 100644 --- a/examples/graphql/src/mikro-orm-config.ts +++ b/examples/graphql/src/mikro-orm-config.ts @@ -1,11 +1,16 @@ +import { type Options } from "@mikro-orm/sqlite"; import { Author } from "./entities/Author"; import { Book } from "./entities/Book"; import { Chat } from "./entities/Chat"; import { Message } from "./entities/Message"; import { Publisher } from "./entities/Publisher"; +import { getFindDataloaderEntityRepository } from "mikro-orm-find-dataloader"; + +export const findDataloaderDefault = false; export default { + entityRepository: getFindDataloaderEntityRepository(findDataloaderDefault), entities: [Author, Book, Chat, Message, Publisher], dbName: ":memory:", debug: true, -}; +} satisfies Options; diff --git a/package.json b/package.json index 56e548e..567d088 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "@changesets/cli": "^2.27.1", "@types/eslint": "^8.44.8", "@types/jest": "^29.5.10", - "@types/node": "^20.10.1", + "@types/node": "^20.10.2", "@typescript-eslint/eslint-plugin": "^6.13.1", "@typescript-eslint/parser": "^6.13.1", "eslint": "^8.54.0", @@ -40,7 +40,7 @@ "husky": "^8.0.3", "jest": "^29.7.0", "lint-staged": "^15.1.0", - "nodemon": "^3.0.1", + "nodemon": "^3.0.2", "prettier": "^3.1.0", "rimraf": "^5.0.5", "ts-jest": "^29.1.1", diff --git a/packages/find/src/EntityDataLoader.ts b/packages/find/src/EntityDataLoader.ts deleted file mode 100644 index 02fecff..0000000 --- a/packages/find/src/EntityDataLoader.ts +++ /dev/null @@ -1,227 +0,0 @@ -/* 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 { - // $and?: ExpandQuery[]; - $or?: Array>; - // $eq?: ExpandScalar | ExpandScalar[]; - // $ne?: ExpandScalar; - // $in?: ExpandScalar[]; - // $nin?: ExpandScalar[]; - // $not?: ExpandQuery; - // $gt?: ExpandScalar; - // $gte?: ExpandScalar; - // $lt?: ExpandScalar; - // $lte?: ExpandScalar; - // $like?: string; - // $re?: string; - // $ilike?: string; - // $fulltext?: string; - // $overlap?: string[]; - // $contains?: string[]; - // $contained?: string[]; - // $exists?: boolean; -} - -export type FilterValueDataloader = - /* OperatorMapDataloader> | */ - FilterItemValue | FilterItemValue[] | null; - -export type QueryDataloader = T extends object - ? T extends Scalar - ? never - : FilterQueryDataloader - : FilterValueDataloader; - -export type FilterObjectDataloader = { - -readonly [K in EntityKey]?: - | QueryDataloader> - | FilterValueDataloader> - | null; -}; - -export type Compute = { - [K in keyof T]: T[K]; -} & {}; - -export type ObjectQueryDataloader = Compute & FilterObjectDataloader>; - -// FilterQuery -export type FilterQueryDataloader = - | ObjectQueryDataloader - | NonNullable>> // 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 & OperatorMapDataloader> - | FilterQueryDataloader[]; - -export class EntityDataLoader = any, P extends string = never, F extends string = never> { - private readonly bypass: boolean; - private readonly findLoader: DataLoader< - Omit, "filtersAndKeys">, - Array> | Loaded | null - >; - - constructor( - private readonly em: EntityManager, - bypass: boolean = false, - ) { - this.bypass = bypass; - - this.findLoader = new DataLoader< - Omit, "filtersAndKeys">, - Array> | Loaded | null - >(async (dataloaderFinds) => { - const queriesMap = groupFindQueries(dataloaderFinds); - assertHasNewFilterAndMapKey(dataloaderFinds); - const promises = Array.from(queriesMap, async ([key, [filter, options]]): Promise<[string, any[]]> => { - const entityName = key.substring(0, key.indexOf("|")); - const findOptions = { - ...(options?.populate != null && { - populate: options.populate === true ? ["*"] : Array.from(options.populate), - }), - } satisfies Pick, "populate">; - const entities = await em.getRepository(entityName).find(filter, findOptions); - return [key, entities]; - }); - const resultsMap = new Map(await Promise.all(promises)); - - return dataloaderFinds.map(({ filtersAndKeys, many }) => { - const res = filtersAndKeys.reduce((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(entity: K, filter: FilterQueryDataloader): 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( - repoOrClass: EntityRepository | EntityName, - filter: FilterQueryDataloader, - options?: Pick, "populate"> & { bypass?: boolean }, - ): Promise>> { - // Property 'entityName' is protected and only accessible within class 'EntityRepository' 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, options) - : this.em.find(repoOrClass, filter as FilterQuery, options)) - : await (this.findLoader.load({ - entityName, - meta: this.em.getMetadata().get(entityName), - filter: filter as FilterQueryDataloader, - options: options as Pick, "populate">, - many: true, - }) as unknown as Promise>>); - } - - async findOne( - repoOrClass: EntityRepository | EntityName, - filter: FilterQueryDataloader, - options?: Pick, "populate"> & { bypass?: boolean }, - ): Promise | null> { - // Property 'entityName' is protected and only accessible within class 'EntityRepository' 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, options) - : this.em.findOne(repoOrClass, filter as FilterQuery, options)) - : await (this.findLoader.load({ - entityName, - meta: this.em.getMetadata().get(entityName), - filter: filter as FilterQueryDataloader, - options: options as Pick, "populate">, - many: false, - }) as unknown as Promise | null>); - } - - async findOneOrFail( - repoOrClass: EntityRepository | EntityName, - filter: FilterQueryDataloader, - options?: Pick, "populate"> & { bypass?: boolean }, - ): Promise> { - // Property 'entityName' is protected and only accessible within class 'EntityRepository' 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, options) - : this.em.findOneOrFail(repoOrClass, filter as FilterQuery, options)); - } - const one = (await this.findLoader.load({ - entityName, - meta: this.em.getMetadata().get(entityName), - filter: filter as FilterQueryDataloader, - options: options as Pick, "populate">, - many: false, - })) as unknown as Loaded | null; - if (one == null) { - throw new Error("Cannot find result"); - } - return one; - } -} diff --git a/packages/find/src/find.test.ts b/packages/find/src/find.test.ts index 9ed08eb..2f9f3d5 100644 --- a/packages/find/src/find.test.ts +++ b/packages/find/src/find.test.ts @@ -1,6 +1,5 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ import { - type SqlEntityManager, MikroORM, Entity, PrimaryKey, @@ -10,8 +9,27 @@ import { Ref, ref, ManyToOne, + EntityRepositoryType, + SimpleLogger, + type SqlEntityManager, } from "@mikro-orm/sqlite"; -import { EntityDataLoader } from "./EntityDataLoader"; +import { type IFindDataloaderEntityRepository, getFindDataloaderEntityRepository } from "./findRepository"; +import { type LoggerNamespace } from "@mikro-orm/core"; + +function mockLogger( + orm: MikroORM, + debug: LoggerNamespace[] = ["query", "query-params"], + mock = jest.fn(), +): jest.Mock { + const logger = orm.config.getLogger(); + Object.assign(logger, { writer: mock }); + orm.config.set("debug", debug); + logger.setDebugMode(debug); + + return mock; +} + +const findDataloaderDefault = true; @Entity() class Author { @@ -24,6 +42,8 @@ class Author { @OneToMany(() => Book, (book) => book.author) books = new Collection(this); + [EntityRepositoryType]?: IFindDataloaderEntityRepository; + constructor({ id, name }: { id?: number; name: string }) { if (id != null) { this.id = id; @@ -43,6 +63,8 @@ class Book { @ManyToOne(() => Author, { ref: true }) author: Ref; + [EntityRepositoryType]?: IFindDataloaderEntityRepository; + constructor({ id, title, author }: { id?: number; title: string; author: Author | Ref }) { if (id != null) { this.id = id; @@ -76,13 +98,14 @@ async function populateDatabase(em: MikroORM["em"]): Promise { describe("find", () => { let orm: MikroORM; - let emFork: SqlEntityManager; - let dataloader: EntityDataLoader; + let em: SqlEntityManager; beforeAll(async () => { orm = await MikroORM.init({ + entityRepository: getFindDataloaderEntityRepository(findDataloaderDefault), dbName: ":memory:", entities: [Author, Book], + loggerFactory: (options) => new SimpleLogger(options), }); try { await orm.schema.clearDatabase(); @@ -95,23 +118,28 @@ describe("find", () => { }); beforeEach(async () => { - emFork = orm.em.fork(); - dataloader = new EntityDataLoader(orm.em.fork()); + em = orm.em.fork(); }); it("should fetch books with the find dataloader", async () => { - const authors = await emFork.find(Author, {}); - const authorBooks = await Promise.all(authors.map(async ({ id }) => await dataloader.find(Book, { author: id }))); + const authors = await em.fork().find(Author, {}); + const mock = mockLogger(orm); + const authorBooks = await Promise.all( + authors.map(async ({ id }) => await em.getRepository(Book).find({ author: id })), + ); expect(authorBooks).toBeDefined(); expect(authorBooks).toMatchSnapshot(); + expect(mock.mock.calls).toEqual([ + ["[query] select `b0`.* from `book` as `b0` where `b0`.`author_id` in (1, 2, 3, 4, 5)"], + ]); }); it("should return the same books as find", async () => { - const authors = await emFork.find(Author, {}); + const authors = await em.fork().find(Author, {}); const dataloaderBooks = await Promise.all( - authors.map(async ({ id }) => await dataloader.find(Book, { author: id })), + authors.map(async ({ id }) => await em.getRepository(Book).find({ author: id })), ); - const findBooks = await Promise.all(authors.map(async ({ id }) => await emFork.find(Book, { author: id }))); + const findBooks = await Promise.all(authors.map(async ({ id }) => await em.fork().find(Book, { author: id }))); expect(dataloaderBooks.map((res) => res.map(({ id }) => id))).toEqual( findBooks.map((res) => res.map(({ id }) => id)), ); diff --git a/packages/find/src/findDataloader.ts b/packages/find/src/findDataloader.ts index 0a4a6e6..e174240 100644 --- a/packages/find/src/findDataloader.ts +++ b/packages/find/src/findDataloader.ts @@ -1,16 +1,84 @@ import { + Utils, Collection, + helper, type FindOptions, - Utils, type AnyEntity, type Reference, type Primary, type EntityMetadata, - helper, type EntityKey, + type FilterItemValue, + type Scalar, + type ExpandProperty, + type ExpandQuery, + type ExpandScalar, + type EntityProps, + type EntityManager, + type EntityName, } from "@mikro-orm/core"; -import { type FilterQueryDataloader } from "./EntityDataLoader"; import { type PartialBy } from "./types"; +import type DataLoader from "dataloader"; + +/* eslint-disable @typescript-eslint/ban-types */ +/* eslint-disable @typescript-eslint/array-type */ + +export interface OperatorMapDataloader { + // $and?: ExpandQuery[]; + $or?: Array>; + // $eq?: ExpandScalar | ExpandScalar[]; + // $ne?: ExpandScalar; + // $in?: ExpandScalar[]; + // $nin?: ExpandScalar[]; + // $not?: ExpandQuery; + // $gt?: ExpandScalar; + // $gte?: ExpandScalar; + // $lt?: ExpandScalar; + // $lte?: ExpandScalar; + // $like?: string; + // $re?: string; + // $ilike?: string; + // $fulltext?: string; + // $overlap?: string[]; + // $contains?: string[]; + // $contained?: string[]; + // $exists?: boolean; +} + +export type FilterValueDataloader = + /* OperatorMapDataloader> | */ + FilterItemValue | FilterItemValue[] | null; + +export type QueryDataloader = T extends object + ? T extends Scalar + ? never + : FilterQueryDataloader + : FilterValueDataloader; + +export type FilterObjectDataloader = { + -readonly [K in EntityKey]?: + | QueryDataloader> + | FilterValueDataloader> + | null; +}; + +export type Compute = { + [K in keyof T]: T[K]; +} & {}; + +export type ObjectQueryDataloader = Compute & FilterObjectDataloader>; + +// FilterQuery +export type FilterQueryDataloader = + | ObjectQueryDataloader + | NonNullable>> // 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 & OperatorMapDataloader> + | FilterQueryDataloader[]; + +/* eslint-enable @typescript-eslint/ban-types */ +/* eslint-enable @typescript-eslint/array-type */ export function groupPrimaryKeysByEntity>( refs: Array>, @@ -218,7 +286,7 @@ export interface DataloaderFind, "filtersAndKeys">>, ): Map, { populate?: true | Set }?]> { const queriesMap = new Map, { populate?: true | Set }?]>(); @@ -231,7 +299,6 @@ export function groupFindQueries( let queryMap = queriesMap.get(key); if (queryMap == null) { queryMap = [structuredClone(newFilter), {}]; - updateQueryFilter(queryMap, newFilter); queriesMap.set(key, queryMap); } else { updateQueryFilter(queryMap, newFilter, options); @@ -241,6 +308,77 @@ export function groupFindQueries( return queriesMap; } +export function getFindBatchLoadFn( + em: EntityManager, + entityName: EntityName, +): DataLoader.BatchLoadFn, "filtersAndKeys">, any> { + return async (dataloaderFinds: Array, "filtersAndKeys">>) => { + const optsMap = groupFindQueriesByOpts(dataloaderFinds); + assertHasNewFilterAndMapKey(dataloaderFinds); + + const promises = optsMapToQueries(optsMap, em, entityName); + const resultsMap = new Map(await Promise.all(promises)); + + return dataloaderFinds.map(({ filtersAndKeys, many }) => { + const res = filtersAndKeys.reduce((acc, { key, newFilter }) => { + const entities = resultsMap.get(key); + if (entities == null) { + // Should never happen + /* istanbul ignore next */ + throw new Error("Cannot match results"); + } + const res = entities[many ? "filter" : "find"]((entity) => { + return filterResult(entity, newFilter); + }); + acc.push(...(Array.isArray(res) ? res : [res])); + return acc; + }, []); + return many ? res : res[0] ?? null; + }); + + function filterResult(entity: K, filter: FilterQueryDataloader): 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; + } + }; +} + +export function optsMapToQueries( + optsMap: Map, { populate?: true | Set }?]>, + em: EntityManager, + entityName: EntityName, +): Array> { + return Array.from(optsMap, async ([key, [filter, options]]): Promise<[string, any[]]> => { + const findOptions = { + ...(options?.populate != null && { + populate: options.populate === true ? ["*"] : Array.from(options.populate), + }), + } satisfies Pick, "populate">; + const entities = await em.find(entityName, filter, findOptions); + return [key, entities]; + }); +} + export function assertHasNewFilterAndMapKey( dataloaderFinds: Array, "filtersAndKeys">>, ): asserts dataloaderFinds is Array> { diff --git a/packages/find/src/findRepository.ts b/packages/find/src/findRepository.ts new file mode 100644 index 0000000..df0c195 --- /dev/null +++ b/packages/find/src/findRepository.ts @@ -0,0 +1,146 @@ +/* eslint-disable @typescript-eslint/method-signature-style */ +import { + EntityRepository, + type EntityManager, + type FindOptions, + type Loaded, + type EntityName, + Utils, + type FilterQuery, + type FindOneOptions, + type FindOneOrFailOptions, +} from "@mikro-orm/core"; +import DataLoader from "dataloader"; +import { type FilterQueryDataloader, getFindBatchLoadFn } from "./findDataloader"; + +export interface IFindDataloaderEntityRepository + extends EntityRepository { + readonly dataloader: D; + + find( + where: FilterQuery, + options?: { dataloader: false } & FindOptions, + ): Promise>>; + find( + where: FilterQueryDataloader, + options?: { dataloader: boolean } & Pick, "populate">, + ): Promise>>; + find( + where: D extends true ? FilterQueryDataloader : FilterQueryDataloader | FilterQuery, + options?: { dataloader?: undefined } & (D extends true + ? Pick, "populate"> + : FindOptions), + ): Promise>>; + + findOne( + where: FilterQuery, + options?: { dataloader: false } & FindOneOptions, + ): Promise | null>; + findOne( + where: FilterQueryDataloader, + options?: { dataloader: boolean } & Pick, "populate">, + ): Promise | null>; + findOne( + where: D extends true ? FilterQueryDataloader : FilterQueryDataloader | FilterQuery, + options?: { dataloader?: undefined } & (D extends true + ? Pick, "populate"> + : FindOneOptions), + ): Promise | null>; + + findOneOrFail( + where: FilterQuery, + options?: { dataloader: false } & FindOneOrFailOptions, + ): Promise>; + findOneOrFail( + where: FilterQueryDataloader, + options?: { dataloader: boolean } & Pick, "populate">, + ): Promise>; + findOneOrFail( + where: D extends true ? FilterQueryDataloader : FilterQueryDataloader | FilterQuery, + options?: { dataloader?: undefined } & (D extends true + ? Pick, "populate"> + : FindOneOrFailOptions), + ): Promise>; +} + +export type FindDataloaderEntityRepositoryCtor = new ( + em: EntityManager, + entityName: EntityName, +) => IFindDataloaderEntityRepository; + +export function getFindDataloaderEntityRepository( + defaultEnabled: D, +): FindDataloaderEntityRepositoryCtor { + class FindDataloaderEntityRepository + extends EntityRepository + implements IFindDataloaderEntityRepository + { + readonly dataloader = defaultEnabled; + private readonly findLoader = new DataLoader(getFindBatchLoadFn(this.em, this.entityName)); + + async find( + where: FilterQueryDataloader | FilterQuery, + options?: { dataloader?: boolean } & ( + | Pick, "populate"> + | FindOptions + ), + ): Promise>> { + const entityName = Utils.className(this.entityName); + const res = await (options?.dataloader ?? this.dataloader + ? this.findLoader.load({ + entityName, + meta: this.em.getMetadata().get(entityName), + filter: where, + options, + many: true, + }) + : this.em.find(this.entityName, where as FilterQuery, options)); + return res as Array>; + } + + async findOne( + where: FilterQueryDataloader | FilterQuery, + options?: { dataloader?: boolean } & ( + | Pick, "populate"> + | FindOneOptions + ), + ): Promise | null> { + const entityName = Utils.className(this.entityName); + const res = await (options?.dataloader ?? this.dataloader + ? this.findLoader.load({ + entityName, + meta: this.em.getMetadata().get(entityName), + filter: where, + options, + many: false, + }) + : this.em.findOne(this.entityName, where as FilterQuery, options)); + return res as Loaded | null; + } + + async findOneOrFail( + where: FilterQueryDataloader | FilterQuery, + options?: { dataloader?: boolean } & ( + | Pick, "populate"> + | FindOneOrFailOptions + ), + ): Promise> { + const entityName = Utils.className(this.entityName); + const res = await (options?.dataloader ?? this.dataloader + ? this.findLoader.load({ + entityName, + meta: this.em.getMetadata().get(entityName), + filter: where, + options, + many: false, + }) + : this.em.findOneOrFail(this.entityName, where as FilterQuery, options)); + if (res == null) { + throw new Error("Cannot find result"); + } + return res as Loaded; + } + } + + return FindDataloaderEntityRepository as FindDataloaderEntityRepositoryCtor; +} diff --git a/packages/find/src/index.ts b/packages/find/src/index.ts index a4d00f2..23e86fe 100644 --- a/packages/find/src/index.ts +++ b/packages/find/src/index.ts @@ -1 +1,2 @@ -export * from "./EntityDataLoader"; +export * from "./findRepository"; +export * from "./findDataloader"; diff --git a/yarn.lock b/yarn.lock index 3816055..5f55f19 100644 --- a/yarn.lock +++ b/yarn.lock @@ -727,8 +727,8 @@ __metadata: linkType: hard "@eslint/eslintrc@npm:^2.1.3": - version: 2.1.3 - resolution: "@eslint/eslintrc@npm:2.1.3" + version: 2.1.4 + resolution: "@eslint/eslintrc@npm:2.1.4" dependencies: ajv: "npm:^6.12.4" debug: "npm:^4.3.2" @@ -739,7 +739,7 @@ __metadata: js-yaml: "npm:^4.1.0" minimatch: "npm:^3.1.2" strip-json-comments: "npm:^3.1.1" - checksum: 77b70a89232fe702c2f765b5b92970f5e4224b55363b923238b996c66fcd991504f40d3663c0543ae17d6c5049ab9b07ab90b65d7601e6f25e8bcd4caf69ac75 + checksum: 7a3b14f4b40fc1a22624c3f84d9f467a3d9ea1ca6e9a372116cb92507e485260359465b58e25bcb6c9981b155416b98c9973ad9b796053fd7b3f776a6946bce8 languageName: node linkType: hard @@ -1373,7 +1373,7 @@ __metadata: languageName: node linkType: hard -"@pkgr/utils@npm:^2.3.1": +"@pkgr/utils@npm:^2.4.2": version: 2.4.2 resolution: "@pkgr/utils@npm:2.4.2" dependencies: @@ -1577,12 +1577,12 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*, @types/node@npm:^20.10.1": - version: 20.10.1 - resolution: "@types/node@npm:20.10.1" +"@types/node@npm:*, @types/node@npm:^20.10.2": + version: 20.10.2 + resolution: "@types/node@npm:20.10.2" dependencies: undici-types: "npm:~5.26.4" - checksum: 703c3cc5bdb2818a16f87019fe4072bfd66489bb300338970260c5b84dd2129595995c41b28773c3b7d9d1a64f36fec59a741629ec466f2aeddad7a9d0c027ac + checksum: e88d0e92870ec4880642cc39250903a098443d791e864a08d08f4e7fdca0c4c9c0233a6fd98bec356f0ebabc6551152a4590d1c9c34b73a95c2b33935f59185f languageName: node linkType: hard @@ -2841,7 +2841,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:4.3.4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4": +"debug@npm:4, debug@npm:4.3.4, debug@npm:^4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4": version: 4.3.4 resolution: "debug@npm:4.3.4" dependencies: @@ -3063,9 +3063,9 @@ __metadata: linkType: hard "electron-to-chromium@npm:^1.4.535": - version: 1.4.600 - resolution: "electron-to-chromium@npm:1.4.600" - checksum: e7bc30f8ad7162e67dde4119be58b54e73cd901990f5de278bfc4ce117656cfdb050bb1bc9816dbdbe16ba5704dbe0e96010ae74a0c9659f187f9f3817e93472 + version: 1.4.601 + resolution: "electron-to-chromium@npm:1.4.601" + checksum: 6a7e510156a1ecfb58a9569592d1ccc8d6089f2e764b5267d9e627e4a81ef4e15f4cdcce8cee4c0355af8df50069ca980c76913aa9a2026bfdffd7c31ef82ad7 languageName: node linkType: hard @@ -6141,12 +6141,12 @@ __metadata: languageName: node linkType: hard -"nodemon@npm:^3.0.1": - version: 3.0.1 - resolution: "nodemon@npm:3.0.1" +"nodemon@npm:^3.0.2": + version: 3.0.2 + resolution: "nodemon@npm:3.0.2" dependencies: chokidar: "npm:^3.5.2" - debug: "npm:^3.2.7" + debug: "npm:^4" ignore-by-default: "npm:^1.0.1" minimatch: "npm:^3.1.2" pstree.remy: "npm:^1.1.8" @@ -6157,7 +6157,7 @@ __metadata: undefsafe: "npm:^2.0.5" bin: nodemon: bin/nodemon.js - checksum: a0e614f8b22317009afde2e87a16517f7791ec37d2b732cf7444d8e05d8f8003ffa519c82e617bad5b9248b9d1b0b3fbb4c28d6dad57332451230cb53364c50e + checksum: 092373295426be7e0dd2f31f3473b53aa4c6cb1b888903a56ff9d84a71f19d75c6d0b322099eff7de164ed31539b8c9a9f038fcad3963a01249189f62a67f4a7 languageName: node linkType: hard @@ -6967,7 +6967,7 @@ __metadata: "@changesets/cli": "npm:^2.27.1" "@types/eslint": "npm:^8.44.8" "@types/jest": "npm:^29.5.10" - "@types/node": "npm:^20.10.1" + "@types/node": "npm:^20.10.2" "@typescript-eslint/eslint-plugin": "npm:^6.13.1" "@typescript-eslint/parser": "npm:^6.13.1" eslint: "npm:^8.54.0" @@ -6981,7 +6981,7 @@ __metadata: husky: "npm:^8.0.3" jest: "npm:^29.7.0" lint-staged: "npm:^15.1.0" - nodemon: "npm:^3.0.1" + nodemon: "npm:^3.0.2" prettier: "npm:^3.1.0" rimraf: "npm:^5.0.5" ts-jest: "npm:^29.1.1" @@ -7572,12 +7572,12 @@ __metadata: linkType: hard "synckit@npm:^0.8.5": - version: 0.8.5 - resolution: "synckit@npm:0.8.5" + version: 0.8.6 + resolution: "synckit@npm:0.8.6" dependencies: - "@pkgr/utils": "npm:^2.3.1" - tslib: "npm:^2.5.0" - checksum: fb6798a2db2650ca3a2435ad32d4fc14842da807993a1a350b64d267e0e770aa7f26492b119aa7500892d3d07a5af1eec7bfbd6e23a619451558be0f226a6094 + "@pkgr/utils": "npm:^2.4.2" + tslib: "npm:^2.6.2" + checksum: 565c659b5c935905e3774f8a53b013aeb1db03b69cb26cfea742021a274fba792e6ec22f1f918bfb6a7fe16dc9ab6e32a94b4289a8d5d9039b695cd9d524953d languageName: node linkType: hard @@ -7790,7 +7790,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:2.6.2, tslib@npm:^2.1.0, tslib@npm:^2.3.1, tslib@npm:^2.4.0, tslib@npm:^2.5.0, tslib@npm:^2.5.2, tslib@npm:^2.6.0": +"tslib@npm:2.6.2, tslib@npm:^2.1.0, tslib@npm:^2.3.1, tslib@npm:^2.4.0, tslib@npm:^2.5.0, tslib@npm:^2.5.2, tslib@npm:^2.6.0, tslib@npm:^2.6.2": version: 2.6.2 resolution: "tslib@npm:2.6.2" checksum: bd26c22d36736513980091a1e356378e8b662ded04204453d353a7f34a4c21ed0afc59b5f90719d4ba756e581a162ecbf93118dc9c6be5acf70aa309188166ca