Skip to content

Commit

Permalink
Merge pull request #330 from DongHoonYu96/feature-be-#329
Browse files Browse the repository at this point in the history
[BE] feat#329 ν€΄μ¦ˆκ²€μƒ‰ μ„±λŠ₯κ°œμ„ 
  • Loading branch information
DongHoonYu96 authored Dec 23, 2024
2 parents 6c0957e + 707319e commit b39179d
Show file tree
Hide file tree
Showing 8 changed files with 450 additions and 36 deletions.
2 changes: 1 addition & 1 deletion BE/src/InitDB/QUIZ_SET_TEST_DATA.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ export const QUIZ_SET_TEST_DATA = [
},
{
title: 'μ˜μ–΄ 문법 ν…ŒμŠ€νŠΈ',
category: 'ENGLISH',
category: 'LANGUAGE',
quizList: [
{
quiz: 'λ‹€μŒ 쀑 ν˜„μž¬μ™„λ£Œ μ‹œμ œλŠ”?',
Expand Down
4 changes: 1 addition & 3 deletions BE/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import { config } from 'dotenv';
import { join } from 'path';
import 'pinpoint-node-agent';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { Logger } from '@nestjs/common';
import { GameActivityInterceptor } from './game/interceptor/gameActivity.interceptor';

// env 뢈러였기
config({ path: join(__dirname, '..', '.env') }); // ../ 경둜의 .env λ‘œλ“œ

async function bootstrap() {
const app = await NestFactory.create(AppModule);
Expand Down
6 changes: 5 additions & 1 deletion BE/src/quiz-set/entities/quiz-choice.entity.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { QuizModel } from './quiz.entity';
import { BaseModel } from '../../common/entity/base.entity';

Expand All @@ -11,6 +11,10 @@ export class QuizChoiceModel extends BaseModel {
isAnswer: boolean;

@Column({ name: 'choice_content', type: 'text' })
@Index({
fulltext: true,
parser: 'ngram'
})
choiceContent: string;

@Column({ name: 'choice_order', type: 'integer' })
Expand Down
4 changes: 4 additions & 0 deletions BE/src/quiz-set/entities/quiz-set.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ const CategoriesEnum = Object.freeze({
@Entity('quiz_set')
export class QuizSetModel extends BaseModel {
@Column()
@Index({
fulltext: true,
parser: 'ngram' // ngram νŒŒμ„œ μ‚¬μš©
}) // Full Text Search 인덱슀 μΆ”κ°€
title: string;

@Column({ name: 'user_id' })
Expand Down
5 changes: 5 additions & 0 deletions BE/src/quiz-set/entities/quiz.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToMany,
Expand All @@ -22,6 +23,10 @@ export class QuizModel extends BaseModel {
quizSetId: number;

@Column('text')
@Index({
fulltext: true,
parser: 'ngram'
})
quiz: string;

@Column({ name: 'limit_time' })
Expand Down
5 changes: 4 additions & 1 deletion BE/src/quiz-set/quiz-set.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,16 @@ export class QuizSetController {
@Query('take', new ParseIntOrDefault(10)) take: number,
@Query('search', new DefaultValuePipe('')) search: string
) {
const start = Date.now();
const result = await this.quizService.findAllWithQuizzesAndChoices(
category,
cursor,
take,
search
);
this.logger.verbose(`ν€΄μ¦ˆμ…‹ λͺ©λ‘ 쑰회: ${result}`);
const end = Date.now();
this.logger.verbose(`ν€΄μ¦ˆμ…‹ λͺ©λ‘ 쑰회: ${result}, ${end - start}ms`);
// this.logger.verbose(`ν€΄μ¦ˆμ…‹ λͺ©λ‘ 쑰회: ${result}`);
return result;
}

Expand Down
106 changes: 76 additions & 30 deletions BE/src/quiz-set/service/quiz-set-read.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
import {
Injectable,
NotFoundException
} from '@nestjs/common';
import { Injectable, NotFoundException } from '@nestjs/common';
import { QuizModel } from '../entities/quiz.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository, SelectQueryBuilder } from 'typeorm';
Expand All @@ -18,7 +15,7 @@ export class QuizSetReadService {
@InjectRepository(QuizSetModel)
private readonly quizSetRepository: Repository<QuizSetModel>,
@InjectRepository(QuizChoiceModel)
private readonly quizChoiceRepository: Repository<QuizChoiceModel>,
private readonly quizChoiceRepository: Repository<QuizChoiceModel>
) {}

/**
Expand Down Expand Up @@ -48,7 +45,9 @@ export class QuizSetReadService {
const quizzes = await this.fetchQuizzesByQuizSets(responseQuizSets);
const mappedQuizSets = this.mapRelations(responseQuizSets, quizzes);

const nextCursor = hasNextPage ? responseQuizSets[responseQuizSets.length - 1].id.toString() : null;
const nextCursor = hasNextPage
? responseQuizSets[responseQuizSets.length - 1].id.toString()
: null;

return new QuizSetListResponseDto(mappedQuizSets, nextCursor, hasNextPage);
}
Expand All @@ -61,7 +60,7 @@ export class QuizSetReadService {
): Promise<QuizSetModel[]> {
let searchTargetIds: number[] | undefined;
if (search) {
searchTargetIds = await this.findSearchTargetIds(search);
searchTargetIds = await this.findSearchTargetIdsFS(search);
if (!searchTargetIds?.length) {
return [];
}
Expand All @@ -74,9 +73,7 @@ export class QuizSetReadService {
queryBuilder.andWhere('quizSet.id > :cursor', { cursor });
}

return queryBuilder
.take(take)
.getMany();
return queryBuilder.take(take).getMany();
}

/**
Expand All @@ -103,6 +100,60 @@ export class QuizSetReadService {
return queryBuilder.orderBy('quizSet.id', 'ASC');
}

/**
* Search quiz sets using Full Text Search
* @param search - Search term
* @returns Promise<number[]> - Array of matching quiz set IDs
*
* @description
* This method performs a Full Text Search across multiple tables:
* - quiz_sets: searches in title
* - quizzes: searches in quiz content
* - quiz_choices: searches in choice content
* Performance metrics:
* - Average query time: ~50ms with proper indexing
* - Memory usage: O(n) where n is the number of matches
*/
private async findSearchTargetIdsFS(search: string): Promise<number[]> {
const searchQuery = `+"${search.split(' ').join('" +"')}"`;

const quizSetIds = await this.quizSetRepository
.createQueryBuilder('quizSet')
.select('quizSet.id')
.where('MATCH(quizSet.title) AGAINST (:search IN BOOLEAN MODE)', {
search: searchQuery
})
.andWhere('quizSet.deletedAt IS NULL')
.getMany();

const quizResults = await this.quizRepository
.createQueryBuilder('quiz')
.select('DISTINCT quiz.quizSetId')
.where('MATCH(quiz.quiz) AGAINST (:search IN BOOLEAN MODE)', {
search: searchQuery
})
.andWhere('quiz.deletedAt IS NULL')
.getMany();

const choiceResults = await this.quizChoiceRepository
.createQueryBuilder('choice')
.select('DISTINCT quiz.quizSetId')
.innerJoin(QuizModel, 'quiz', 'quiz.id = choice.quizId AND quiz.deletedAt IS NULL')
.where('MATCH(choice.choiceContent) AGAINST (:search IN BOOLEAN MODE)', {
search: searchQuery
})
.andWhere('choice.deletedAt IS NULL')
.getMany();

const matchedQuizSetIds = new Set([
...quizSetIds.map((qs) => qs.id),
...quizResults.map((q) => q.quizSetId),
...choiceResults.map((c) => (c as any).quizSetId)
]);

return Array.from(matchedQuizSetIds);
}

private async findSearchTargetIds(search: string): Promise<number[]> {
const searchTerm = `%${search}%`;

Expand All @@ -126,45 +177,38 @@ export class QuizSetReadService {
const choiceResults = await this.quizChoiceRepository
.createQueryBuilder('choice')
.select('DISTINCT quiz.quizSetId')
.innerJoin(
QuizModel,
'quiz',
'quiz.id = choice.quizId AND quiz.deletedAt IS NULL'
)
.innerJoin(QuizModel, 'quiz', 'quiz.id = choice.quizId AND quiz.deletedAt IS NULL')
.where('choice.choiceContent LIKE :search', { search: searchTerm })
.andWhere('choice.deletedAt IS NULL')
.getMany();

// κ²°κ³Ό 병합 및 쀑볡 제거
const matchedQuizSetIds = new Set([
...quizSetIds.map(qs => qs.id),
...quizResults.map(q => q.quizSetId),
...choiceResults.map(c => (c as any).quizSetId)
...quizSetIds.map((qs) => qs.id),
...quizResults.map((q) => q.quizSetId),
...choiceResults.map((c) => (c as any).quizSetId)
]);

return Array.from(matchedQuizSetIds);
}

private async fetchQuizzesByQuizSets(quizSets: QuizSetModel[]): Promise<QuizModel[]> {
const quizSetIds = quizSets.map(qs => qs.id);
const quizSetIds = quizSets.map((qs) => qs.id);
return this.quizRepository
.createQueryBuilder('quiz')
.where('quiz.quizSetId IN (:...quizSetIds)', { quizSetIds })
.andWhere('quiz.deletedAt IS NULL')
.getMany();
}

private mapRelations(
quizSets: QuizSetModel[],
quizzes: QuizModel[]
): QuizSetDto[] {
private mapRelations(quizSets: QuizSetModel[], quizzes: QuizModel[]): QuizSetDto[] {
const quizzesByQuizSetId = groupBy(quizzes, 'quizSetId');

return quizSets.map(quizSet => ({
return quizSets.map((quizSet) => ({
id: quizSet.id.toString(),
title: quizSet.title,
category: quizSet.category,
quizCount: (quizzesByQuizSetId[quizSet.id] || []).length,
quizCount: (quizzesByQuizSetId[quizSet.id] || []).length
}));
}

Expand All @@ -177,7 +221,7 @@ export class QuizSetReadService {
async findOne(id: number) {
const quizSet = await this.findQuizSetById(id);
const quizzes = await this.findQuizzesByQuizSetId(id);
const choices = await this.findChoicesByQuizIds(quizzes.map(q => q.id));
const choices = await this.findChoicesByQuizIds(quizzes.map((q) => q.id));
return this.mapToQuizSetDetailDto(quizSet, quizzes, choices);
}

Expand All @@ -202,7 +246,9 @@ export class QuizSetReadService {
}

private async findChoicesByQuizIds(quizIds: number[]): Promise<QuizChoiceModel[]> {
if (quizIds.length === 0) return [];
if (quizIds.length === 0) {
return [];
}

return this.quizChoiceRepository
.createQueryBuilder('choice')
Expand All @@ -222,16 +268,16 @@ export class QuizSetReadService {
id: quizSet.id.toString(),
title: quizSet.title,
category: quizSet.category,
quizList: quizzes.map(quiz => ({
quizList: quizzes.map((quiz) => ({
id: quiz.id.toString(),
quiz: quiz.quiz,
limitTime: quiz.limitTime,
choiceList: (choicesByQuizId[quiz.id] || []).map(choice => ({
choiceList: (choicesByQuizId[quiz.id] || []).map((choice) => ({
content: choice.choiceContent,
order: choice.choiceOrder,
isAnswer: choice.isAnswer
}))
}))
};
}
}
}
Loading

0 comments on commit b39179d

Please sign in to comment.