diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 8fe26ff..d807e82 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,7 +2,7 @@ name: Tests -# Controls when the action will run. +# Controls when the action will run. on: # Triggers the workflow on push or pull request events but only for the master branch push: @@ -26,22 +26,22 @@ jobs: steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - uses: actions/checkout@v2 - + - name: env sync - run: cp .env.dist .env && export $(cat ./.env | xargs) + run: cp .env.dist .env && export $(cat ./.env | xargs) # Runs a single command using the runners shell - name: build docker db - run: docker-compose up -d - + run: docker compose up -d + - name: install run: yarn install --ignore-scripts - + - name: build run: yarn build - name: check docker - run: docker-compose up -d + run: docker compose up -d # Runs a set of commands using the runners shell - name: tests diff --git a/docker-compose.yml b/docker-compose.yml index 5770d89..fa14cc0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,10 +1,8 @@ -version: "3" - services: db: image: mysql:5.7 env_file: .env - ports: + ports: - 3306:3306 volumes: - db_data:/var/lib/mysql diff --git a/src/__tests__/polymorphic.repository.spec.ts b/src/__tests__/polymorphic.repository.spec.ts index dc8db6b..da0c22f 100644 --- a/src/__tests__/polymorphic.repository.spec.ts +++ b/src/__tests__/polymorphic.repository.spec.ts @@ -1,4 +1,4 @@ -import { DataSource, Repository } from 'typeorm'; +import { DataSource, DeepPartial } from 'typeorm'; import { AdvertEntity } from './entities/advert.entity'; import { UserEntity } from './entities/user.entity'; import { config } from 'dotenv'; @@ -324,4 +324,124 @@ describe('AbstractPolymorphicRepository', () => { }); }); }); + + describe('Batch', () => { + describe('hydrate', () => { + it('Can hydrate entities with polymorphic relationships properly', async () => { + const advertRepository = AbstractPolymorphicRepository.createRepository( + connection, + AdvertRepository, + ); + const userRepository = AbstractPolymorphicRepository.createRepository( + connection, + UserRepository, + ); + const merchantRepository = connection.getRepository(MerchantEntity); + + const user = await userRepository.save(new UserEntity()); + const user2 = await userRepository.save(new UserEntity()); + const user3 = await userRepository.save(new UserEntity()); + const merchant = await merchantRepository.save(new MerchantEntity()); + const merchant2 = await merchantRepository.save(new MerchantEntity()); + + type ManifestItem = { + config: DeepPartial; + advert: AdvertEntity | null; + user: UserEntity | null; + }; + const manifest: Array = [ + { config: { owner: user }, advert: null, user: user }, + { config: { owner: user2 }, advert: null, user: user2 }, + { config: { owner: user3 }, advert: null, user: user3 }, + { config: { owner: merchant }, advert: null, user: null }, + { config: { owner: merchant2 }, advert: null, user: null }, + { config: { creator: merchant2 }, advert: null, user: null }, + { + config: { owner: user, creator: merchant2 }, + advert: null, + user: user, + }, + { + config: { owner: user2, creator: merchant2 }, + advert: null, + user: user2, + }, + ]; + + // save all the items first the maximize the chance of + // hydration errors + for (const item of manifest) { + const entity = await advertRepository.save( + advertRepository.create(item.config), + ); + item.advert = entity; + } + + /******************************** + * test advert hydration (parent) + ********************************/ + const adverts = await advertRepository.find(); + const advertManifestMap = manifest.reduce((acc, item) => { + if (item.advert) { + acc[item.advert.id] = item; + } + return acc; + }, {}); + + for (const advert of adverts) { + const manifestItem = advertManifestMap[advert.id]; + if (!manifestItem) { + throw new Error('this should not happen.'); + } + + const { + config: { owner, creator }, + } = manifestItem; + if (owner) { + expect(advert.owner).toBeInstanceOf(owner.constructor); + expect(advert.owner.id).toBe(owner.id); + } + + if (creator) { + expect(advert.creator).toBeInstanceOf(creator.constructor); + expect(advert.creator.id).toBe(creator.id); + } + } + + /******************************** + * test user hydration (child) + ********************************/ + const users = await userRepository.find(); + const usersAdvertMap = manifest.reduce>( + (acc, item) => { + if (item.user && item.advert) { + acc[item.user.id] = acc[item.user.id] || []; + acc[item.user.id].push(item.advert); + } + return acc; + }, + {}, + ); + + for (const user of users) { + const adverts = usersAdvertMap[user.id]; + if (!adverts || !adverts.length) { + throw new Error('this should not happen.'); + } + + const actualIds = user.adverts + .map((advert) => { + return advert.id; + }) + .sort(); + const expectedIds = adverts + .map((advert) => { + return advert.id; + }) + .sort(); + expect(actualIds).toEqual(expectedIds); + } + }); + }); + }); }); diff --git a/src/polymorphic.repository.ts b/src/polymorphic.repository.ts index 40a9ef5..c83e8a9 100644 --- a/src/polymorphic.repository.ts +++ b/src/polymorphic.repository.ts @@ -1,10 +1,12 @@ import 'reflect-metadata'; import { + Brackets, DataSource, DeepPartial, FindManyOptions, FindOneOptions, getMetadataArgsStorage, + In, ObjectLiteral, Repository, SaveOptions, @@ -22,12 +24,6 @@ import { EntityRepositoryMetadataArgs } from 'typeorm/metadata-args/EntityReposi import { RepositoryNotFoundException } from './repository.token.exception'; import { POLYMORPHIC_REPOSITORY } from './constants'; -type PolymorphicHydrationType = { - key: string; - type: 'children' | 'parent'; - values: PolymorphicChildInterface[] | PolymorphicChildInterface; -}; - const entityTypeColumn = (options: PolymorphicMetadataInterface): string => options.entityTypeColumn || 'entityType'; const entityIdColumn = (options: PolymorphicMetadataInterface): string => @@ -105,98 +101,186 @@ export abstract class AbstractPolymorphicRepository< } public async hydrateMany(entities: E[]): Promise { - return Promise.all(entities.map((ent) => this.hydrateOne(ent))); + return this.hydratePolymorphs(entities); } public async hydrateOne(entity: E): Promise { + return (await this.hydratePolymorphs([entity]))[0]; + } + + private async hydratePolymorphs(entities: E[]) { + if (!this.isPolymorph()) { + return entities; + } + const metadata = this.getPolymorphicMetadata(); + const groupedMetadata = metadata.reduce< + Record< + string, + { + entityType: Function; + metadata: PolymorphicMetadataInterface[]; + } + > + >((acc, meta) => { + const entityTypes = this.getEntityTypes(meta); + for (const entityType of entityTypes) { + acc[entityType.name] = acc[entityType.name] || { + entityType, + metadata: [], + }; + + acc[entityType.name].metadata.push(meta); + } + return acc; + }, {}); + + const groupedMetadataKeys = Object.keys(groupedMetadata); + for (const key of groupedMetadataKeys) { + // hydrate each entityType in batch based on the associated metadata/properties + const { entityType, metadata } = groupedMetadata[key]; + await this.findAndHydrateEntityTypeForPolymorphicOptions({ + entities, + entityType, + metadata, + }); + } - return this.hydratePolymorphs(entity, metadata); + return entities; } - private async hydratePolymorphs( - entity: E, - options: PolymorphicMetadataInterface[], - ): Promise { - const values = await Promise.all( - options.map((option: PolymorphicMetadataInterface) => - this.hydrateEntities(entity, option), - ), - ); + private async findAndHydrateEntityTypeForPolymorphicOptions({ + entities, + entityType, + metadata, + }: { + entities: E[]; + entityType: Function; + metadata: PolymorphicMetadataInterface[]; + }) { + /** + * Fetch the polymorphs for the given entityType, it's corresponding + * metadata options, and the set of entities we're hydrating. + */ + const repository = this.findRepository(entityType); + const query = repository.createQueryBuilder('p'); - return values.reduce((e: E, vals: PolymorphicHydrationType) => { - const values = - vals.type === 'parent' && Array.isArray(vals.values) - ? vals.values.filter((v) => typeof v !== 'undefined') - : vals.values; - const polys = - vals.type === 'parent' && Array.isArray(values) ? values[0] : values; // TODO should be condition for !hasMany - type EntityKey = keyof E; - const key = vals.key as EntityKey; - e[key] = polys as (typeof e)[typeof key]; - - return e; - }, entity); - } + for (const options of metadata) { + if (this.isParent(options)) { + const parentIds = entities + .filter((entity) => { + return entity[entityTypeColumn(options)] === entityType.name; + }) + .reduce((set, entity) => { + set.add(entity[entityIdColumn(options)]); + return set; + }, new Set()); + query.orWhere({ + [PrimaryColumn(options)]: In([...parentIds]), + }); + } else { + const entityIds = entities.reduce((set, entity) => { + set.add(entity[this.getRepositoryEntityPrimaryColumn()]); + return set; + }, new Set()); + query.orWhere( + new Brackets((qb) => { + const idColumn = entityIdColumn(options); + const typeColumn = entityTypeColumn(options); + qb.where(`p.${idColumn} IN (:...ids)`, { + ids: [...entityIds], + }).andWhere(`p.${typeColumn} = :entityType`, { + entityType: entities[0].constructor.name, + }); + }), + ); + } + } - private async hydrateEntities( - entity: E, - options: PolymorphicMetadataInterface, - ): Promise { - const entityTypes: (Function | string)[] = - options.type === 'parent' - ? [entity[entityTypeColumn(options)]] - : Array.isArray(options.classType) - ? options.classType - : [options.classType]; - - // TODO if not hasMany, should I return if one is found? - const results = await Promise.all( - entityTypes.map((type: Function) => - type ? this.findPolymorphs(entity, type, options) : null, - ), - ); + /** + * Map the fetched polymorphs into their appropriate entities for each + * metadata option. + */ + const polymorphsIdColumn = + this.getRepositoryEntityPrimaryColumn(repository); + const polymorphs = await query.getMany(); + const polymorphsIdColumnMap = polymorphs.reduce((acc, poly) => { + acc[poly[polymorphsIdColumn].toString()] = poly; + return acc; + }, {}); - return { - key: options.propertyKey, - type: options.type, - values: (options.hasMany && - Array.isArray(results) && - results.length > 0 && - Array.isArray(results[0]) - ? results.reduce( - ( - resultEntities: PolymorphicChildInterface[], - entities: PolymorphicChildInterface[], - ) => entities.concat(...resultEntities), - [] as PolymorphicChildInterface[], - ) - : results) as PolymorphicChildInterface | PolymorphicChildInterface[], - }; + for (const options of metadata) { + const key = options.propertyKey as keyof E; + + if (this.isParent(options)) { + const idColumn = entityIdColumn(options); + const entitiesToHydrate = entities.reduce((acc, entity) => { + const isMatch = entity[entityTypeColumn(options)] === entityType.name; + if (isMatch) { + acc.push(entity); + } else if (entity[key] === undefined) { + entity[key] = null; + } + return acc; + }, []); + + entitiesToHydrate.forEach((entity) => { + const poly = polymorphsIdColumnMap[entity[idColumn].toString()]; + + if (!poly) return; + if (options.hasMany) { + entity[key] = entity[key] || ([] as E[keyof E]); + entity[key].push(poly); + } else { + entity[key] = poly; + } + }); + } else { + const idColumn = entityIdColumn(options); + const polymorphsEntityIdMap = polymorphs.reduce< + Record + >((acc, poly) => { + const resolvedValue = poly[idColumn]; + if (resolvedValue !== null || resolvedValue === undefined) { + const resolvedValueKey = resolvedValue.toString(); + acc[resolvedValueKey] = acc[resolvedValueKey] || []; + acc[resolvedValueKey].push(poly); + } + return acc; + }, {}); + entities.forEach((entity) => { + const entityId = + entity[this.getRepositoryEntityPrimaryColumn()].toString(); + const polymorphs = polymorphsEntityIdMap[entityId]; + + if (!polymorphs || !polymorphs.length) return; + if (options.hasMany) { + entity[key] = polymorphs as E[keyof E]; + } else { + entity[key] = polymorphs[0] as E[keyof E]; + } + }); + } + } } - private async findPolymorphs( - parent: E, - entityType: Function, - options: PolymorphicMetadataInterface, - ): Promise { - const repository = this.findRepository(entityType); + private getEntityTypes(options: PolymorphicMetadataInterface): Function[] { + const entityTypes = new Set(); + if (Array.isArray(options.classType)) { + options.classType.forEach((classType) => { + entityTypes.add(classType); + }); + } else { + entityTypes.add(options.classType); + } - return repository[options.hasMany ? 'find' : 'findOne']( - options.type === 'parent' - ? { - where: { - // TODO: Not sure about this change (key was just id before) - [PrimaryColumn(options)]: parent[entityIdColumn(options)], - }, - } - : { - where: { - [entityIdColumn(options)]: parent[PrimaryColumn(options)], - [entityTypeColumn(options)]: parent.constructor.name, - }, - }, - ); + return [...entityTypes]; + } + + private getRepositoryEntityPrimaryColumn(repository: Repository = this) { + const primaryColumnProperty = + repository.metadata.primaryColumns[0].propertyName; + return primaryColumnProperty; } private findRepository( @@ -336,11 +420,7 @@ export abstract class AbstractPolymorphicRepository< return results; } - const metadata = this.getPolymorphicMetadata(); - - return Promise.all( - results.map((entity) => this.hydratePolymorphs(entity, metadata)), - ); + return this.hydratePolymorphs(results); } public async findOne(options?: FindOneOptions): Promise { @@ -356,7 +436,7 @@ export abstract class AbstractPolymorphicRepository< return entity; } - return this.hydratePolymorphs(entity, polymorphicMetadata); + return (await this.hydratePolymorphs([entity]))[0]; } create(): E;