From cc83fede1eec1f6640da04d27c234dc86b203c34 Mon Sep 17 00:00:00 2001 From: ChoiWheatley Date: Sun, 2 Feb 2025 18:26:34 +0900 Subject: [PATCH 01/10] feat(query-subscriber): Add ImageQuerySubscriber for counting statements --- src/app.module.ts | 2 ++ src/features/image/image-query.spec.ts | 36 ++++++++++++++++++++++++++ src/tests/query-subscriber.ts | 23 ++++++++++++++++ 3 files changed, 61 insertions(+) create mode 100644 src/features/image/image-query.spec.ts create mode 100644 src/tests/query-subscriber.ts diff --git a/src/app.module.ts b/src/app.module.ts index c7e1ffbc..28ad3ce0 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -48,6 +48,7 @@ import { CsBoard } from './entities/cs-board.entity'; import { CsComment } from './entities/cs-comment.entity'; import { CsBoardModule } from './features/cs-board/cs-board.module'; import { CsCommentModule } from './features/cs-comment/cs-comment.module'; +import { ImageQuerySubscriber } from './tests/query-subscriber'; @Module({ imports: [ ScheduleModule.forRoot(), @@ -95,6 +96,7 @@ import { CsCommentModule } from './features/cs-comment/cs-comment.module'; rejectUnauthorized: false, }, }, + subscribers: [ImageQuerySubscriber], }), UserModule, FundingModule, 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++; + } +} From cf68aea8e81d5370211a5ddd4583a4d027ac3b3e Mon Sep 17 00:00:00 2001 From: ChoiWheatley Date: Thu, 6 Feb 2025 17:08:24 +0900 Subject: [PATCH 02/10] feat(image-instance-manager): Add mapImage generic for Funding, User, Gift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 현재 하나의 이미지만 map 하는 기능 완성했습니다. 추후 mapImages도 만들어 필요한 경우 entity.images 필드에 매핑하는 코드도 작성해 볼게요! --- src/features/image/image-instance-manager.ts | 117 ++++++++++++++++++- 1 file changed, 114 insertions(+), 3 deletions(-) diff --git a/src/features/image/image-instance-manager.ts b/src/features/image/image-instance-manager.ts index c39d8e89..7846a70f 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,115 @@ export class ImageInstanceManager { ); } + /** + * The function inspects the main 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); + */ + mapImage( + qb: SelectQueryBuilder, + alias?: string, + ): SelectQueryBuilder; + mapImage( + qb: SelectQueryBuilder, + alias?: string, + ): SelectQueryBuilder; + mapImage( + qb: SelectQueryBuilder, + alias?: string, + ): SelectQueryBuilder; + + 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 +147,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'); } } From 44a610e78a2bcef5b885d4c5fe44143ffe7b5bb6 Mon Sep 17 00:00:00 2001 From: ChoiWheatley Date: Thu, 6 Feb 2025 17:08:51 +0900 Subject: [PATCH 03/10] refactor(comment.service): findMany now uses mapImage --- src/features/comment/comment.service.ts | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/features/comment/comment.service.ts b/src/features/comment/comment.service.ts index 2253a644..6801a6a4 100644 --- a/src/features/comment/comment.service.ts +++ b/src/features/comment/comment.service.ts @@ -94,20 +94,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'); // NOTE - 이런 식으로 매핑하고 싶은 엔티티 alias를 붙여주면 됩니다. + const funding = await fundingQb.getOne(); if (!funding) { throw this.g2gException.FundingNotExists; From 0ef87f01a4b22da8220d8aada417cd322a83d027 Mon Sep 17 00:00:00 2001 From: ChoiWheatley Date: Thu, 6 Feb 2025 17:22:40 +0900 Subject: [PATCH 04/10] refactor(comment.service): comment create --- src/features/comment/comment.service.ts | 15 ++++++++------- src/features/image/image-instance-manager.ts | 8 +++++++- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/features/comment/comment.service.ts b/src/features/comment/comment.service.ts index 6801a6a4..9a914258 100644 --- a/src/features/comment/comment.service.ts +++ b/src/features/comment/comment.service.ts @@ -51,15 +51,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, @@ -98,7 +99,7 @@ export class CommentService { .orderBy('comment.regAt', 'DESC'); this.imageInstanceManager.mapImage(fundingQb, 'author'); - // this.imageInstanceManager.mapImage(fundingQb, 'funding'); // NOTE - 이런 식으로 매핑하고 싶은 엔티티 alias를 붙여주면 됩니다. + // this.imageInstanceManager.mapImage(fundingQb, 'funding'); // 이런 식으로 매핑하고 싶은 엔티티 alias를 붙여주면 됩니다. const funding = await fundingQb.getOne(); if (!funding) { diff --git a/src/features/image/image-instance-manager.ts b/src/features/image/image-instance-manager.ts index 7846a70f..4fc4dcdb 100644 --- a/src/features/image/image-instance-manager.ts +++ b/src/features/image/image-instance-manager.ts @@ -59,6 +59,13 @@ export class ImageInstanceManager { 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, @@ -115,7 +122,6 @@ export class ImageInstanceManager { ); } - mapImages( queryBuilder: SelectQueryBuilder, mapToProperty: string, From 4c6179a59ea865e458656b79b0a7d16ecd373327 Mon Sep 17 00:00:00 2001 From: ChoiWheatley Date: Thu, 6 Feb 2025 21:14:10 +0900 Subject: [PATCH 05/10] misc(app.module): comment not used query subscriber --- src/app.module.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app.module.ts b/src/app.module.ts index 28ad3ce0..59f5309c 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -96,7 +96,7 @@ import { ImageQuerySubscriber } from './tests/query-subscriber'; rejectUnauthorized: false, }, }, - subscribers: [ImageQuerySubscriber], + // subscribers: [ImageQuerySubscriber], // TODO - move to tests.module }), UserModule, FundingModule, From 263b46e42e419ce95effdd175fbf229b869992cd Mon Sep 17 00:00:00 2001 From: ChoiWheatley Date: Thu, 6 Feb 2025 21:14:55 +0900 Subject: [PATCH 06/10] refactor(comment.service): comment update --- src/features/comment/comment.service.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/features/comment/comment.service.ts b/src/features/comment/comment.service.ts index 9a914258..14f18ae9 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; @@ -117,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; } From de561a739ddce045fd7192b533da5f840a94cb4e Mon Sep 17 00:00:00 2001 From: ChoiWheatley Date: Thu, 6 Feb 2025 21:19:40 +0900 Subject: [PATCH 07/10] refactor(comment.service): comment.remove --- src/features/comment/comment.service.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/features/comment/comment.service.ts b/src/features/comment/comment.service.ts index 14f18ae9..b6c148a2 100644 --- a/src/features/comment/comment.service.ts +++ b/src/features/comment/comment.service.ts @@ -150,10 +150,20 @@ 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, + }); + this.imageInstanceManager.mapImage(commentQb, 'author'); + + const comment = await commentQb.getOne(); + if (!comment) { + throw this.g2gException.CommentNotFound; + } if (!comment) { throw this.g2gException.CommentNotFound; } @@ -163,9 +173,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); } } From 15ef1b6071a80896342c4eba86943d9afcb81f9a Mon Sep 17 00:00:00 2001 From: ChoiWheatley Date: Thu, 6 Feb 2025 21:20:00 +0900 Subject: [PATCH 08/10] doc(image-instance-manager): change comment for mapImage --- src/features/image/image-instance-manager.ts | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/src/features/image/image-instance-manager.ts b/src/features/image/image-instance-manager.ts index 4fc4dcdb..ec745d00 100644 --- a/src/features/image/image-instance-manager.ts +++ b/src/features/image/image-instance-manager.ts @@ -29,7 +29,7 @@ export class ImageInstanceManager { } /** - * The function inspects the main alias’s metadata to determine the entity type. + * 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) @@ -68,22 +68,9 @@ export class ImageInstanceManager { this.imageInstanceManager.mapImage(authorQb); */ mapImage( - qb: SelectQueryBuilder, + qb: SelectQueryBuilder, alias?: string, - ): SelectQueryBuilder; - mapImage( - qb: SelectQueryBuilder, - alias?: string, - ): SelectQueryBuilder; - mapImage( - qb: SelectQueryBuilder, - alias?: string, - ): SelectQueryBuilder; - - mapImage( - qb: SelectQueryBuilder, - alias?: string, - ): SelectQueryBuilder { + ): SelectQueryBuilder { // Use provided alias or fall back to the query builder's own alias. const entityAlias = alias || qb.alias; From b967cfd70ad8cf6b98514a188fc25768d0ece9c4 Mon Sep 17 00:00:00 2001 From: ChoiWheatley Date: Thu, 6 Feb 2025 22:21:45 +0900 Subject: [PATCH 09/10] fix(comment.service): remove join author in remove --- src/features/comment/comment.service.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/features/comment/comment.service.ts b/src/features/comment/comment.service.ts index b6c148a2..c30760a6 100644 --- a/src/features/comment/comment.service.ts +++ b/src/features/comment/comment.service.ts @@ -153,20 +153,15 @@ export class CommentService { 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; } - if (!comment) { - throw this.g2gException.CommentNotFound; - } // 오직 본인만이 자신이 작성한 댓글을 삭제할 수 있다. await this.validCheck.verifyUserMatch(user.userId, comment.authorId); From e7df62688e97078a5392e86c60e0fd1dcfa4c6ac Mon Sep 17 00:00:00 2001 From: ChoiWheatley Date: Thu, 6 Feb 2025 22:23:15 +0900 Subject: [PATCH 10/10] fix(comment.service): revert join author --- src/features/comment/comment.service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/features/comment/comment.service.ts b/src/features/comment/comment.service.ts index c30760a6..10b470bf 100644 --- a/src/features/comment/comment.service.ts +++ b/src/features/comment/comment.service.ts @@ -153,6 +153,7 @@ export class CommentService { const commentQb = this.commentRepository .createQueryBuilder('comment') .leftJoinAndSelect('comment.funding', 'funding') + .leftJoinAndSelect('comment.author', 'author') .where('comment.comId = :comId AND funding.fundUuid = :fundUuid', { comId, fundUuid,