From 907a60c805c292dcb78bf0ebe6ff5ec807ec714f Mon Sep 17 00:00:00 2001 From: the-sabra Date: Tue, 17 Sep 2024 15:18:43 +0300 Subject: [PATCH] feat: add verse search feature and make index for vers column --- src/shared/paginated-request.dto.ts | 2 +- src/verse/dto/filter-get-verse.dto.ts | 12 +++- src/verse/entities/verse.entity.ts | 4 +- src/verse/verse.controller.spec.ts | 20 ++++-- src/verse/verse.controller.ts | 2 +- src/verse/verse.module.ts | 6 +- src/verse/verse.service.spec.ts | 100 +++++++++++++++++++++++--- src/verse/verse.service.ts | 37 ++++++++-- 8 files changed, 154 insertions(+), 29 deletions(-) diff --git a/src/shared/paginated-request.dto.ts b/src/shared/paginated-request.dto.ts index d3d50a0..7f4cc73 100644 --- a/src/shared/paginated-request.dto.ts +++ b/src/shared/paginated-request.dto.ts @@ -9,7 +9,7 @@ export class paginatedRequest { @IsInt({ message: 'Take must be an integer' }) @IsPositive({ message: 'Take must be a positive integer' }) - @Max(20, { message: 'Take cannot exceed 20' }) + @Max(35, { message: 'Take cannot exceed 20' }) @Transform(({ value }) => parseInt(value)) take: number; } diff --git a/src/verse/dto/filter-get-verse.dto.ts b/src/verse/dto/filter-get-verse.dto.ts index 43fcda7..fa49ad1 100644 --- a/src/verse/dto/filter-get-verse.dto.ts +++ b/src/verse/dto/filter-get-verse.dto.ts @@ -1,9 +1,15 @@ -import { IsInt, Min } from 'class-validator'; +import { IsInt, IsOptional, IsString, Min } from 'class-validator'; import { Transform } from 'class-transformer'; +import { paginatedRequest } from 'src/shared/paginated-request.dto'; -export class GetVerseFilterDto { +export class GetVerseFilterDto extends paginatedRequest { @IsInt() @Min(1) @Transform(({ value }) => parseInt(value)) - surah_id: number; + @IsOptional() + surah_id?: number; + + @IsOptional() + @IsString() + name?: string; } diff --git a/src/verse/entities/verse.entity.ts b/src/verse/entities/verse.entity.ts index ecce5d5..e042757 100644 --- a/src/verse/entities/verse.entity.ts +++ b/src/verse/entities/verse.entity.ts @@ -14,7 +14,9 @@ export class Verse { @PrimaryGeneratedColumn() id: number; - @Column('text') + //for full text search + @Column({ type: 'text', charset: 'utf8mb4' }) + @Index('IDX_VERS', { fulltext: true }) vers: string; @Column('int') diff --git a/src/verse/verse.controller.spec.ts b/src/verse/verse.controller.spec.ts index 7dff4a0..296d657 100644 --- a/src/verse/verse.controller.spec.ts +++ b/src/verse/verse.controller.spec.ts @@ -18,6 +18,7 @@ describe('VerseController', () => { useValue: { create: jest.fn(), getSurahVerses: jest.fn(), + getVerse: jest.fn(), // Add this line if `getVerse` is also needed elsewhere }, }, ], @@ -55,8 +56,12 @@ describe('VerseController', () => { }); describe('getSurahVerses', () => { - it('should call verseService.getSurahVerses with the correct parameters', async () => { - const getVerseFilterDto: GetVerseFilterDto = { surah_id: 1 }; + it('should call verseService.getVerse with the correct parameters', async () => { + const getVerseFilterDto: GetVerseFilterDto = { + surah_id: 1, + page: 1, + take: 10, + }; const verses: Verse[] = [ { id: 1, @@ -75,15 +80,18 @@ describe('VerseController', () => { surah: null, }, ]; - const getSurahVersesResult = { verses, totalVerseNumber: verses.length }; + const getSurahVersesResult = { + verses, + totalData: 27, + totalPages: 2, + } as any; jest - .spyOn(verseService, 'getSurahVerses') + .spyOn(verseService, 'getVerse') .mockResolvedValueOnce(getSurahVersesResult); const result = await verseController.getSurahVerses(getVerseFilterDto); - - expect(verseService.getSurahVerses).toHaveBeenCalledWith(1); + expect(verseService.getVerse).toHaveBeenCalledWith(getVerseFilterDto); expect(result).toEqual(getSurahVersesResult); }); }); diff --git a/src/verse/verse.controller.ts b/src/verse/verse.controller.ts index 7f172ad..ed94125 100644 --- a/src/verse/verse.controller.ts +++ b/src/verse/verse.controller.ts @@ -14,7 +14,7 @@ export class VerseController { @Get('/surah') getSurahVerses(@Query() getVerseFilterDto: GetVerseFilterDto) { - return this.verseService.getSurahVerses(getVerseFilterDto.surah_id); + return this.verseService.getVerse(getVerseFilterDto); } @Get('/random') diff --git a/src/verse/verse.module.ts b/src/verse/verse.module.ts index a61fc85..3de6784 100644 --- a/src/verse/verse.module.ts +++ b/src/verse/verse.module.ts @@ -10,4 +10,8 @@ import { Verse } from './entities/verse.entity'; providers: [VerseService], exports: [VerseService], }) -export class VerseModule {} +export class VerseModule { + constructor(private readonly verseService: VerseService) { + this.verseService.initialVerses(); + } +} diff --git a/src/verse/verse.service.spec.ts b/src/verse/verse.service.spec.ts index b68dcdb..c5d7bde 100644 --- a/src/verse/verse.service.spec.ts +++ b/src/verse/verse.service.spec.ts @@ -5,6 +5,7 @@ import { Verse } from './entities/verse.entity'; import { CreateVerseDto } from './dto/create-verse.dto'; import { Repository } from 'typeorm'; import { InternalServerErrorException, Logger } from '@nestjs/common'; +import { GetVerseFilterDto } from './dto/filter-get-verse.dto'; jest.mock('../../quran.json', () => [ { @@ -84,22 +85,103 @@ describe('VerseService', () => { }); }); - describe('getSurahVerses', () => { - it('should return verses of a surah', async () => { - const surahId = 1; - const verses: Verse[] = []; + describe('getVerse', () => { + const getVerseFilterDto: GetVerseFilterDto = { + surah_id: 1, + name: 'Allah', + page: 1, + take: 10, + }; + + const verses: Verse[] = [ + { + id: 1, + vers: 'In the name of Allah, the Most Merciful, the Most Compassionate.', + verse_number: 1, + vers_lang: 'ar', + surah_id: 1, + } as Verse, + ]; + + const queryBuilderMock = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue(verses), + getCount: jest.fn().mockResolvedValue(1), + leftJoinAndSelect: jest.fn().mockReturnThis(), + }; + + it('should return filtered verses with pagination', async () => { + const queryBuilderMock = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue(verses), + getCount: jest.fn().mockResolvedValue(1), + leftJoinAndSelect: jest.fn().mockReturnThis(), + }; + + jest + .spyOn(verseRepository, 'createQueryBuilder') + .mockReturnValue(queryBuilderMock as any); - jest.spyOn(verseRepository, 'find').mockResolvedValueOnce(verses); + const result = await verseService.getVerse(getVerseFilterDto); - const result = await verseService.getSurahVerses(surahId); + expect(queryBuilderMock.where).toHaveBeenCalledWith('1 = 1'); + expect(queryBuilderMock.andWhere).toHaveBeenCalledWith( + 'verse.surah_id = :surah_id', + { surah_id: 1 }, + ); + expect(queryBuilderMock.andWhere).toHaveBeenCalledWith( + 'MATCH(verse.vers) AGAINST(:name IN NATURAL LANGUAGE MODE)', + { name: 'Allah' }, + ); + expect(queryBuilderMock.skip).toHaveBeenCalledWith(0); + expect(queryBuilderMock.take).toHaveBeenCalledWith(10); + expect(queryBuilderMock.getMany).toHaveBeenCalled(); + expect(queryBuilderMock.getCount).toHaveBeenCalled(); - expect(verseRepository.find).toHaveBeenCalledWith({ - where: { surah_id: surahId }, + expect(result).toEqual({ + verses, + totalData: 1, + totalPages: 1, }); + }); + + it('should return all verses if no filters are provided', async () => { + const basicFilterDto: GetVerseFilterDto = { + page: 1, + take: 10, + }; + + jest + .spyOn(verseRepository, 'createQueryBuilder') + .mockReturnValue(queryBuilderMock as any); + + const result = await verseService.getVerse(basicFilterDto); + + expect(queryBuilderMock.where).toHaveBeenCalledWith('1 = 1'); + expect(queryBuilderMock.andWhere).not.toHaveBeenCalledWith( + 'verse.surah_id = :surah_id', + expect.anything(), + ); + expect(queryBuilderMock.andWhere).not.toHaveBeenCalledWith( + 'MATCH(verse.vers) AGAINST(:name IN NATURAL LANGUAGE MODE)', + expect.anything(), + ); + + expect(queryBuilderMock.skip).toHaveBeenCalledWith(0); + expect(queryBuilderMock.take).toHaveBeenCalledWith(10); + expect(queryBuilderMock.getMany).toHaveBeenCalled(); + expect(queryBuilderMock.getCount).toHaveBeenCalled(); expect(result).toEqual({ verses, - totalVerseNumber: verses.length, + totalData: 1, + totalPages: 1, }); }); }); diff --git a/src/verse/verse.service.ts b/src/verse/verse.service.ts index 9f204ec..615d303 100644 --- a/src/verse/verse.service.ts +++ b/src/verse/verse.service.ts @@ -8,6 +8,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Verse } from './entities/verse.entity'; import { Repository } from 'typeorm'; import { randomInt } from 'node:crypto'; +import { GetVerseFilterDto } from './dto/filter-get-verse.dto'; @Injectable() export class VerseService { @@ -25,15 +26,37 @@ export class VerseService { } } - async getSurahVerses(surah_id: number) { - const verses = await this.verseRepository.find({ - where: { surah_id }, - }); - const result = { + async getVerse(getVerseFilterDto: GetVerseFilterDto) { + const { surah_id, name, page, take } = getVerseFilterDto; + const skip = (page - 1) * take; + const query = this.verseRepository + .createQueryBuilder('verse') + .leftJoinAndSelect('verse.surah', 'surah') + .where(`1 = 1`); + + if (surah_id) { + query.andWhere('verse.surah_id = :surah_id', { surah_id }); + } + + if (name) { + query.andWhere( + 'MATCH(verse.vers) AGAINST(:name IN NATURAL LANGUAGE MODE)', + { name }, + ); + } + + const [verses, totalData] = await Promise.all([ + query.skip(skip).take(take).getMany(), + query.getCount(), + ]); + + const totalPages = Math.ceil(totalData / take); + + return { verses, - totalVerseNumber: verses.length, + totalData, + totalPages, }; - return result; } async initialVerses() {