diff --git a/src/app.module.ts b/src/app.module.ts index 5c216a69..7fb25a04 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -60,6 +60,7 @@ import { EventHandlersModule } from './event-handlers/event-handlers.module'; rejectUnauthorized: false, }, }, + // subscribers: [ImageQuerySubscriber], // TODO - move to tests.module }), UserModule, FundingModule, diff --git a/src/features/comment/comment.service.ts b/src/features/comment/comment.service.ts index 2253a644..10b470bf 100644 --- a/src/features/comment/comment.service.ts +++ b/src/features/comment/comment.service.ts @@ -12,6 +12,7 @@ import { GiftogetherExceptions } from 'src/filters/giftogether-exception'; import { ValidCheck } from 'src/util/valid-check'; import { ImageInstanceManager } from '../image/image-instance-manager'; import { ImageType } from 'src/enums/image-type.enum'; +import { SelectQueryBuilder } from 'typeorm/browser'; function convertToGetCommentDto(comment: Comment): GetCommentDto { const { comId, content, regAt, isMod, authorId, author } = comment; @@ -51,15 +52,16 @@ export class CommentService { if (!funding) { throw this.g2gException.FundingNotExists; } - const author = await this.userRepository.findOne({ - where: { userId: user.userId! }, - }); + const authorQb = this.userRepository + .createQueryBuilder('author') + .where('author.userId = :userId', { userId: user.userId! }); + + this.imageInstanceManager.mapImage(authorQb); + + const author = await authorQb.getOne(); if (!author) { throw this.g2gException.UserNotFound; } - author.image = await this.imageInstanceManager - .getImages(author) - .then((images) => images[0]); const newComment = new Comment({ funding, @@ -94,20 +96,12 @@ export class CommentService { { isDel: false }, ) .leftJoinAndSelect('comment.author', 'author') - .leftJoinAndMapOne( - 'author.image', // map to property 'image' of 'author' - 'image', // property name of 'author' - 'authorImage', // alias of 'image' table - ` - (author.defaultImgId IS NOT NULL AND authorImage.imgId = author.defaultImgId) - OR - (author.defaultImgId IS NULL AND authorImage.subId = author.userId AND authorImage.imgType = :imgType) - `, - { imgType: ImageType.User }, - ) .where('funding.fundUuid = :fundUuid', { fundUuid }) .orderBy('comment.regAt', 'DESC'); + this.imageInstanceManager.mapImage(fundingQb, 'author'); + // this.imageInstanceManager.mapImage(fundingQb, 'funding'); // 이런 식으로 매핑하고 싶은 엔티티 alias를 붙여주면 됩니다. + const funding = await fundingQb.getOne(); if (!funding) { throw this.g2gException.FundingNotExists; @@ -124,10 +118,17 @@ export class CommentService { ): Promise { const { content } = updateCommentDto; - const comment = await this.commentRepository.findOne({ - relations: { funding: true, author: true }, - where: { comId, funding: { fundUuid } }, - }); + const commentQb = this.commentRepository + .createQueryBuilder('comment') + .leftJoinAndSelect('comment.funding', 'funding') + .leftJoinAndSelect('comment.author', 'author') + .where('comment.comId = :comId AND funding.fundUuid = :fundUuid', { + comId, + fundUuid, + }); + this.imageInstanceManager.mapImage(commentQb, 'author'); + + const comment = await commentQb.getOne(); if (!comment) { throw this.g2gException.CommentNotFound; } @@ -149,10 +150,16 @@ export class CommentService { * soft delete */ async remove(user: Partial, fundUuid: string, comId: number) { - const comment = await this.commentRepository.findOne({ - relations: { funding: true, author: true }, - where: { comId, funding: { fundUuid }, isDel: false }, - }); + const commentQb = this.commentRepository + .createQueryBuilder('comment') + .leftJoinAndSelect('comment.funding', 'funding') + .leftJoinAndSelect('comment.author', 'author') + .where('comment.comId = :comId AND funding.fundUuid = :fundUuid', { + comId, + fundUuid, + }); + + const comment = await commentQb.getOne(); if (!comment) { throw this.g2gException.CommentNotFound; } @@ -162,9 +169,6 @@ export class CommentService { comment.isDel = true; this.commentRepository.save(comment); - comment.author.image = await this.imageInstanceManager - .getImages(comment.author) - .then((images) => images[0]); return convertToGetCommentDto(comment); } } diff --git a/src/features/image/image-instance-manager.ts b/src/features/image/image-instance-manager.ts index c39d8e89..ec745d00 100644 --- a/src/features/image/image-instance-manager.ts +++ b/src/features/image/image-instance-manager.ts @@ -1,7 +1,13 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { IImageId } from 'src/interfaces/image-id.interface'; import { ImageService } from './image.service'; import { Image } from 'src/entities/image.entity'; +import { SelectQueryBuilder } from 'typeorm'; +import { T } from 'node_modules/@faker-js/faker/dist/airline-BnpeTvY9.cjs'; +import { ImageType } from 'src/enums/image-type.enum'; +import { User } from 'src/entities/user.entity'; +import { Funding } from 'src/entities/funding.entity'; +import { Gift } from 'src/entities/gift.entity'; @Injectable() export class ImageInstanceManager { @@ -22,13 +28,108 @@ export class ImageInstanceManager { ); } + /** + * The function inspects the alias’s metadata to determine the entity type. + * It then uses that to build a join condition that first checks if the entity’s + * defaultImgId is set (in which case it matches image.imgId to defaultImgId). If not, + * it falls back to matching image.subId to the entity’s id field (userId, fundId, or giftId) + * and image.imgType to the proper ImageType. + * + * @param qb The TypeORM query builder for one of the entities. + * @param alias Optional alias for the entity in the query. + * @returns The query builder with the left join mapped to the `image` property. + * @see https://orkhan.gitbook.io/typeorm/docs/select-query-builder#joining-and-mapping-functionality + * @example + const fundingQb = this.fundingRepository + .createQueryBuilder('funding') + .leftJoinAndSelect( + 'funding.comments', + 'comment', + 'comment.isDel = :isDel', + { isDel: false }, + ) + .leftJoinAndSelect('comment.author', 'author') + .where('funding.fundUuid = :fundUuid', { fundUuid }) + .orderBy('comment.regAt', 'DESC'); + + // NOTE - 이런 식으로 매핑하고 싶은 엔티티 alias를 붙여주면 됩니다. + this.imageInstanceManager.mapImage(fundingQb, 'author'); + this.imageInstanceManager.mapImage(fundingQb, 'funding'); + + const funding = await fundingQb.getOne(); + console.log(funding.image.imgUrl); + console.log(funding.author.image.imgUrl); + + const authorQb = this.userRepository + .createQueryBuilder('author') + .where('author.userId = :userId', { userId: user.userId! }); + + // NOTE - 또는 아래와 같이 alias를 생략하면 query builder의 main alias가 자동으로 사용됩니다. + this.imageInstanceManager.mapImage(authorQb); + */ + mapImage( + qb: SelectQueryBuilder, + alias?: string, + ): SelectQueryBuilder { + // Use provided alias or fall back to the query builder's own alias. + const entityAlias = alias || qb.alias; + + // Determine the correct id field and image type by inspecting the query builder's metadata. + let idField: string; + let imgType: ImageType; + const joinedAlias = qb.expressionMap.findAliasByName(entityAlias); + const target = joinedAlias.metadata.target; + + if (target === User) { + idField = 'userId'; + imgType = ImageType.User; + } else if (target === Funding) { + idField = 'fundId'; + imgType = ImageType.Funding; + } else if (target === Gift) { + idField = 'giftId'; + imgType = ImageType.Gift; + } else { + throw new Error(`mapImage does not support entity type: ${target}`); + } + + // Build the left join condition: + // If defaultImgId is set then join on image.imgId; + // otherwise, join on image.subId matching the entity id and image.imgType. + return qb.leftJoinAndMapOne( + `${entityAlias}.image`, // the property to map the result to (e.g. user.image) + 'image', // the Image entity + `${entityAlias}Image`, // alias for the joined image table + ` + (${entityAlias}.defaultImgId IS NOT NULL AND ${entityAlias}Image.imgId = ${entityAlias}.defaultImgId) + OR + (${entityAlias}.defaultImgId IS NULL AND ${entityAlias}Image.subId = ${entityAlias}.${idField} AND ${entityAlias}Image.imgType = :imgType) + `, + { imgType }, + ); + } + + mapImages( + queryBuilder: SelectQueryBuilder, + mapToProperty: string, + property: string, + imgType: ImageType, + ): SelectQueryBuilder { + return queryBuilder.leftJoinAndMapOne(mapToProperty, property, 'i', ``, { + imgType, + }); + } + /** * 이미지 Update 수행중 새 이미지 URL로 교체하는 유스케이스에 대응합니다. * 기본이미지로 교체하고 싶다면 `resetToDefault`를 사용하세요. * TODO - Implement * @param newImageUrls not default image URL */ - async overwrite(entity: T, newImageUrls: string[]): Promise { + async overwrite( + entity: T, + newImageUrls: string[], + ): Promise { throw new Error('Not Implemented'); } @@ -39,7 +140,10 @@ export class ImageInstanceManager { * TODO - Implement * @param defaultImgId 여기에선 이 id가 기본 이미지인지 여부를 검사하지 않습니다. */ - async resetToDefault(entity: T, defaultImgId: number): Promise { + async resetToDefault( + entity: T, + defaultImgId: number, + ): Promise { throw new Error('Not Implemented'); } } diff --git a/src/features/image/image-query.spec.ts b/src/features/image/image-query.spec.ts new file mode 100644 index 00000000..77011f4d --- /dev/null +++ b/src/features/image/image-query.spec.ts @@ -0,0 +1,36 @@ +import { DataSource } from 'typeorm'; +import { Test } from '@nestjs/testing'; + +describe('TypeORM Query Counting', () => { + let dataSource: DataSource; + let queryCount = 0; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [], + }).compile(); + + dataSource = moduleRef.get(DataSource); + + // Listen to all queries executed + dataSource.driver.connection.on('query', () => { + queryCount++; + }); + }); + + beforeEach(() => { + queryCount = 0; // Reset the count before each test + }); + + it('should execute expected number of queries', async () => { + // Perform the database operation + await dataSource.getRepository(SomeEntity).find(); + + console.log(`Executed SQL Queries: ${queryCount}`); + expect(queryCount).toBeGreaterThan(0); // Replace with expected count + }); + + afterAll(async () => { + await dataSource.destroy(); + }); +}); \ No newline at end of file diff --git a/src/tests/query-subscriber.ts b/src/tests/query-subscriber.ts new file mode 100644 index 00000000..93f7ba17 --- /dev/null +++ b/src/tests/query-subscriber.ts @@ -0,0 +1,23 @@ +import { Image } from 'src/entities/image.entity'; +import { EntitySubscriberInterface, EventSubscriber } from 'typeorm'; + +/** + * 모든 Image 쿼리의 실행횟수를 검사합니다. + */ +@EventSubscriber() +export class ImageQuerySubscriber implements EntitySubscriberInterface { + private _queryCount = 0; + + public get queryCount() { + return this._queryCount; + } + + listenTo() { + return Image; + } + + beforeQuery() { + console.log(`Executed SQL Queries: ${this.queryCount}`); + this._queryCount++; + } +}