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/packages/find/package.json b/packages/find/package.json index 47edf4b..8a82bf3 100644 --- a/packages/find/package.json +++ b/packages/find/package.json @@ -55,6 +55,7 @@ }, "devDependencies": { "@mikro-orm/core": "6.0.0-dev.220", + "@mikro-orm/postgresql": "6.0.0-dev.220", "@mikro-orm/sqlite": "6.0.0-dev.220" } } 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..30360c1 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,7 @@ export function groupFindQueries( let queryMap = queriesMap.get(key); if (queryMap == null) { queryMap = [structuredClone(newFilter), {}]; - updateQueryFilter(queryMap, newFilter); + // updateQueryFilter(queryMap, newFilter); queriesMap.set(key, queryMap); } else { updateQueryFilter(queryMap, newFilter, options); @@ -241,6 +309,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..f81b639 --- /dev/null +++ b/packages/find/src/findRepository.ts @@ -0,0 +1,142 @@ +/* eslint-disable @typescript-eslint/method-signature-style */ +import { + EntityRepository, + type SqlEntityManager, + type FindOptions, + type Loaded, + type EntityName, + Utils, + type FilterQuery, + type FindOneOptions, + type FindOneOrFailOptions, +} from "@mikro-orm/postgresql"; +import DataLoader from "dataloader"; +import { type FilterQueryDataloader, getFindBatchLoadFn } from "./findDataloader"; + +export interface IFindDataloaderEntityRepository + extends EntityRepository { + readonly dataloader: D; + + find( + where: FilterQueryDataloader, + options?: { dataloader: true } & Pick, "populate">, + ): Promise>>; + find( + where: FilterQuery, + options?: { dataloader: false } & FindOptions, + ): Promise>>; + find( + where: D extends true ? FilterQueryDataloader : FilterQueryDataloader | FilterQuery, + options?: { dataloader?: undefined } & (D extends true + ? Pick, "populate"> + : FindOptions), + ): Promise>>; + + findOne( + where: FilterQueryDataloader, + options?: { dataloader: true } & Pick, "populate">, + ): Promise | null>; + findOne( + where: FilterQuery, + options?: { dataloader: false } & FindOneOptions, + ): Promise | null>; + findOne( + where: D extends true ? FilterQueryDataloader : FilterQueryDataloader | FilterQuery, + options?: { dataloader?: undefined } & (D extends true + ? Pick, "populate"> + : FindOneOptions), + ): Promise | null>; + + findOneOrFail( + where: FilterQueryDataloader, + options?: { dataloader: true } & Pick, "populate">, + ): Promise>; + findOneOrFail( + where: FilterQuery, + options?: { dataloader: false } & FindOneOrFailOptions, + ): Promise>; + findOneOrFail( + where: D extends true ? FilterQueryDataloader : FilterQueryDataloader | FilterQuery, + options?: { dataloader?: undefined } & (D extends true + ? Pick, "populate"> + : FindOneOrFailOptions), + ): Promise>; +} + +export type FindDataloaderEntityRepositoryCtor = new ( + em: SqlEntityManager, + 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 this.findLoader.load({ + entityName, + meta: this.em.getMetadata().get(entityName), + filter: where, + options, + many: false, + }); + 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 this.findLoader.load({ + entityName, + meta: this.em.getMetadata().get(entityName), + filter: where, + options, + many: false, + }); + 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..1e26918 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1283,6 +1283,18 @@ __metadata: languageName: node linkType: hard +"@mikro-orm/postgresql@npm:6.0.0-dev.220": + version: 6.0.0-dev.220 + resolution: "@mikro-orm/postgresql@npm:6.0.0-dev.220" + dependencies: + "@mikro-orm/knex": "npm:6.0.0-dev.220" + pg: "npm:8.11.3" + peerDependencies: + "@mikro-orm/core": 6.0.0-dev.220 + checksum: 0e3de63a7b0c7b4ac6a617ac799949b45d08ff7dd8dd5a538328f2be3eacc2d7bd6a9514ee545af9edd7af27eba648338ba99322903caf19fa226f5e279c8b9e + languageName: node + linkType: hard + "@mikro-orm/sqlite@npm:6.0.0-dev.220": version: 6.0.0-dev.220 resolution: "@mikro-orm/sqlite@npm:6.0.0-dev.220" @@ -2368,6 +2380,13 @@ __metadata: languageName: node linkType: hard +"buffer-writer@npm:2.0.0": + version: 2.0.0 + resolution: "buffer-writer@npm:2.0.0" + checksum: fdca8e28c55704de7af2f41c8f875293de69ad22005d5041d54aa916d125cead00afa969bc09e4702ae6b66e098409958c06bebfc97fcf8fa4ea5afcae088cd9 + languageName: node + linkType: hard + "builtin-modules@npm:^3.3.0": version: 3.3.0 resolution: "builtin-modules@npm:3.3.0" @@ -5841,6 +5860,7 @@ __metadata: resolution: "mikro-orm-find-dataloader@workspace:packages/find" dependencies: "@mikro-orm/core": "npm:6.0.0-dev.220" + "@mikro-orm/postgresql": "npm:6.0.0-dev.220" "@mikro-orm/sqlite": "npm:6.0.0-dev.220" tslib: "npm:2.6.2" peerDependencies: @@ -6457,6 +6477,13 @@ __metadata: languageName: node linkType: hard +"packet-reader@npm:1.0.0": + version: 1.0.0 + resolution: "packet-reader@npm:1.0.0" + checksum: 8504cc8c32672380867e933516a029b1d4dd784c139213c85c9042ffc1162de48ec914f8c71260a9311518694cf5d0be11c67357f4b536129d2ea42aa7257ec0 + languageName: node + linkType: hard + "parent-module@npm:^1.0.0": version: 1.0.1 resolution: "parent-module@npm:1.0.1" @@ -6530,6 +6557,13 @@ __metadata: languageName: node linkType: hard +"pg-cloudflare@npm:^1.1.1": + version: 1.1.1 + resolution: "pg-cloudflare@npm:1.1.1" + checksum: 45ca0c7926967ec9e66a9efc73ca57e3e933671b541bc774631a02ce683e7f658d0a4e881119b3f61486f38e344ae1b008d3a20eb5e21701c5fa8ff8382c5538 + languageName: node + linkType: hard + "pg-connection-string@npm:2.6.1": version: 2.6.1 resolution: "pg-connection-string@npm:2.6.1" @@ -6537,6 +6571,82 @@ __metadata: languageName: node linkType: hard +"pg-connection-string@npm:^2.6.2": + version: 2.6.2 + resolution: "pg-connection-string@npm:2.6.2" + checksum: 22265882c3b6f2320785378d0760b051294a684989163d5a1cde4009e64e84448d7bf67d9a7b9e7f69440c3ee9e2212f9aa10dd17ad6773f6143c6020cebbcb5 + languageName: node + linkType: hard + +"pg-int8@npm:1.0.1": + version: 1.0.1 + resolution: "pg-int8@npm:1.0.1" + checksum: a1e3a05a69005ddb73e5f324b6b4e689868a447c5fa280b44cd4d04e6916a344ac289e0b8d2695d66e8e89a7fba023affb9e0e94778770ada5df43f003d664c9 + languageName: node + linkType: hard + +"pg-pool@npm:^3.6.1": + version: 3.6.1 + resolution: "pg-pool@npm:3.6.1" + peerDependencies: + pg: ">=8.0" + checksum: 5d1b02b959e6c849004d8f3d2222c48d3b3b67b7b1eb5f2e5819ed9412129ea6b0f0376bc74ddf197973c99575d325cbb3f64a8017ab520535c011329b12fffb + languageName: node + linkType: hard + +"pg-protocol@npm:^1.6.0": + version: 1.6.0 + resolution: "pg-protocol@npm:1.6.0" + checksum: 995864cc2a8517368b84697c753caff769a4db292eda66f96d9eec46e3aa84737cd0b0fe171aca9d7d4b4a4c46bb25bd399713cb1027a5bf8f38adea0b4284f4 + languageName: node + linkType: hard + +"pg-types@npm:^2.1.0": + version: 2.2.0 + resolution: "pg-types@npm:2.2.0" + dependencies: + pg-int8: "npm:1.0.1" + postgres-array: "npm:~2.0.0" + postgres-bytea: "npm:~1.0.0" + postgres-date: "npm:~1.0.4" + postgres-interval: "npm:^1.1.0" + checksum: 87a84d4baa91378d3a3da6076c69685eb905d1087bf73525ae1ba84b291b9dd8738c6716b333d8eac6cec91bf087237adc3e9281727365e9cbab0d9d072778b1 + languageName: node + linkType: hard + +"pg@npm:8.11.3": + version: 8.11.3 + resolution: "pg@npm:8.11.3" + dependencies: + buffer-writer: "npm:2.0.0" + packet-reader: "npm:1.0.0" + pg-cloudflare: "npm:^1.1.1" + pg-connection-string: "npm:^2.6.2" + pg-pool: "npm:^3.6.1" + pg-protocol: "npm:^1.6.0" + pg-types: "npm:^2.1.0" + pgpass: "npm:1.x" + peerDependencies: + pg-native: ">=3.0.1" + dependenciesMeta: + pg-cloudflare: + optional: true + peerDependenciesMeta: + pg-native: + optional: true + checksum: f15f29c8e17723ee1da72abdf400cbed2c04602c58c93687f3f0068e71df2a6fb62b9a3543e13da21b10a0494f4c5b4cfc8d6cd8396617b76c4cbfd6ddab17e7 + languageName: node + linkType: hard + +"pgpass@npm:1.x": + version: 1.0.5 + resolution: "pgpass@npm:1.0.5" + dependencies: + split2: "npm:^4.1.0" + checksum: 0a6f3bf76e36bdb3c20a7e8033140c732767bba7e81f845f7489fc3123a2bd6e3b8e704f08cba86b117435414b5d2422e20ba9d5f2efb6f0c75c9efca73e8e87 + languageName: node + linkType: hard + "picocolors@npm:^1.0.0": version: 1.0.0 resolution: "picocolors@npm:1.0.0" @@ -6583,6 +6693,36 @@ __metadata: languageName: node linkType: hard +"postgres-array@npm:~2.0.0": + version: 2.0.0 + resolution: "postgres-array@npm:2.0.0" + checksum: aff99e79714d1271fe942fec4ffa2007b755e7e7dc3d2feecae3f1ceecb86fd3637c8138037fc3d9e7ec369231eeb136843c0b25927bf1ce295245a40ef849b4 + languageName: node + linkType: hard + +"postgres-bytea@npm:~1.0.0": + version: 1.0.0 + resolution: "postgres-bytea@npm:1.0.0" + checksum: d844ae4ca7a941b70e45cac1261a73ee8ed39d72d3d74ab1d645248185a1b7f0ac91a3c63d6159441020f4e1f7fe64689ac56536a307b31cef361e5187335090 + languageName: node + linkType: hard + +"postgres-date@npm:~1.0.4": + version: 1.0.7 + resolution: "postgres-date@npm:1.0.7" + checksum: 571ef45bec4551bb5d608c31b79987d7a895141f7d6c7b82e936a52d23d97474c770c6143e5cf8936c1cdc8b0dfd95e79f8136bf56a90164182a60f242c19f2b + languageName: node + linkType: hard + +"postgres-interval@npm:^1.1.0": + version: 1.2.0 + resolution: "postgres-interval@npm:1.2.0" + dependencies: + xtend: "npm:^4.0.0" + checksum: 746b71f93805ae33b03528e429dc624706d1f9b20ee81bf743263efb6a0cd79ae02a642a8a480dbc0f09547b4315ab7df6ce5ec0be77ed700bac42730f5c76b2 + languageName: node + linkType: hard + "preferred-pm@npm:^3.0.0": version: 3.1.2 resolution: "preferred-pm@npm:3.1.2" @@ -7310,6 +7450,13 @@ __metadata: languageName: node linkType: hard +"split2@npm:^4.1.0": + version: 4.2.0 + resolution: "split2@npm:4.2.0" + checksum: 09bbefc11bcf03f044584c9764cd31a252d8e52cea29130950b26161287c11f519807c5e54bd9e5804c713b79c02cefe6a98f4688630993386be353e03f534ab + languageName: node + linkType: hard + "sprintf-js@npm:~1.0.2": version: 1.0.3 resolution: "sprintf-js@npm:1.0.3" @@ -8282,6 +8429,13 @@ __metadata: languageName: node linkType: hard +"xtend@npm:^4.0.0": + version: 4.0.2 + resolution: "xtend@npm:4.0.2" + checksum: ac5dfa738b21f6e7f0dd6e65e1b3155036d68104e67e5d5d1bde74892e327d7e5636a076f625599dc394330a731861e87343ff184b0047fef1360a7ec0a5a36a + languageName: node + linkType: hard + "y18n@npm:^4.0.0": version: 4.0.3 resolution: "y18n@npm:4.0.3"