diff --git a/src/main/java/com/jeju/nanaland/domain/common/dto/SearchDto.java b/src/main/java/com/jeju/nanaland/domain/common/dto/SearchDto.java new file mode 100644 index 00000000..1fcc9e58 --- /dev/null +++ b/src/main/java/com/jeju/nanaland/domain/common/dto/SearchDto.java @@ -0,0 +1,36 @@ +package com.jeju.nanaland.domain.common.dto; + +import com.querydsl.core.annotations.QueryProjection; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class SearchDto { + + private Long id; + private String title; + private ImageFileDto firstImage; + private Long matchedCount; + private LocalDateTime createdAt; + + @QueryProjection + public SearchDto(Long id, String title, String originUrl, String thumbnailUrl, + Long matchedCount, LocalDateTime createdAt) { + this.id = id; + this.title = title; + this.firstImage = new ImageFileDto(originUrl, thumbnailUrl); + this.matchedCount = matchedCount; + this.createdAt = createdAt; + } + + public void addMatchedCount(Long count) { + this.matchedCount += count; + } +} diff --git a/src/main/java/com/jeju/nanaland/domain/experience/dto/ExperienceSearchDto.java b/src/main/java/com/jeju/nanaland/domain/experience/dto/ExperienceSearchDto.java new file mode 100644 index 00000000..a3ce2511 --- /dev/null +++ b/src/main/java/com/jeju/nanaland/domain/experience/dto/ExperienceSearchDto.java @@ -0,0 +1,22 @@ +package com.jeju.nanaland.domain.experience.dto; + +import com.jeju.nanaland.domain.common.dto.SearchDto; +import com.querydsl.core.annotations.QueryProjection; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ExperienceSearchDto extends SearchDto { + + @QueryProjection + public ExperienceSearchDto(Long id, String title, String originUrl, String thumbnailUrl, + Long matchedCount, + LocalDateTime createdAt) { + super(id, title, originUrl, thumbnailUrl, matchedCount, createdAt); + } +} diff --git a/src/main/java/com/jeju/nanaland/domain/experience/repository/ExperienceRepositoryCustom.java b/src/main/java/com/jeju/nanaland/domain/experience/repository/ExperienceRepositoryCustom.java index a8898157..15da749d 100644 --- a/src/main/java/com/jeju/nanaland/domain/experience/repository/ExperienceRepositoryCustom.java +++ b/src/main/java/com/jeju/nanaland/domain/experience/repository/ExperienceRepositoryCustom.java @@ -6,6 +6,7 @@ import com.jeju.nanaland.domain.common.dto.PostPreviewDto; import com.jeju.nanaland.domain.experience.dto.ExperienceCompositeDto; import com.jeju.nanaland.domain.experience.dto.ExperienceResponse.ExperienceThumbnail; +import com.jeju.nanaland.domain.experience.dto.ExperienceSearchDto; import com.jeju.nanaland.domain.experience.entity.enums.ExperienceType; import com.jeju.nanaland.domain.experience.entity.enums.ExperienceTypeKeyword; import com.jeju.nanaland.domain.review.dto.ReviewResponse.SearchPostForReviewDto; @@ -20,9 +21,6 @@ public interface ExperienceRepositoryCustom { ExperienceCompositeDto findCompositeDtoByIdWithPessimisticLock(Long id, Language language); - Page searchCompositeDtoByKeyword(String keyword, Language language, - Pageable pageable); - Page findExperienceThumbnails(Language language, ExperienceType experienceType, List keywordFilterList, List addressTags, Pageable pageable); @@ -44,5 +42,9 @@ PopularPostPreviewDto findRandomPopularPostPreviewDtoByLanguage(Language languag PopularPostPreviewDto findPostPreviewDtoByLanguageAndId(Language language, Long postId); + Page findSearchDtoByKeywordsUnion(List keywords, Language language, + Pageable pageable); + Page findSearchDtoByKeywordsIntersect(List keywords, + Language language, Pageable pageable); } diff --git a/src/main/java/com/jeju/nanaland/domain/experience/repository/ExperienceRepositoryImpl.java b/src/main/java/com/jeju/nanaland/domain/experience/repository/ExperienceRepositoryImpl.java index 27ec1d1a..a2e13746 100644 --- a/src/main/java/com/jeju/nanaland/domain/experience/repository/ExperienceRepositoryImpl.java +++ b/src/main/java/com/jeju/nanaland/domain/experience/repository/ExperienceRepositoryImpl.java @@ -16,24 +16,35 @@ import com.jeju.nanaland.domain.common.dto.QPostPreviewDto; import com.jeju.nanaland.domain.experience.dto.ExperienceCompositeDto; import com.jeju.nanaland.domain.experience.dto.ExperienceResponse.ExperienceThumbnail; +import com.jeju.nanaland.domain.experience.dto.ExperienceSearchDto; import com.jeju.nanaland.domain.experience.dto.QExperienceCompositeDto; import com.jeju.nanaland.domain.experience.dto.QExperienceResponse_ExperienceThumbnail; +import com.jeju.nanaland.domain.experience.dto.QExperienceSearchDto; import com.jeju.nanaland.domain.experience.entity.enums.ExperienceType; import com.jeju.nanaland.domain.experience.entity.enums.ExperienceTypeKeyword; +import com.jeju.nanaland.domain.hashtag.entity.QKeyword; import com.jeju.nanaland.domain.review.dto.QReviewResponse_SearchPostForReviewDto; import com.jeju.nanaland.domain.review.dto.ReviewResponse.SearchPostForReviewDto; +import com.querydsl.core.Tuple; import com.querydsl.core.group.GroupBy; +import com.querydsl.core.types.Expression; import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.CaseBuilder; import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.core.types.dsl.StringExpression; import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import jakarta.persistence.LockModeType; import java.util.ArrayList; import java.util.Collections; +import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.support.PageableExecutionUtils; @@ -41,6 +52,7 @@ @RequiredArgsConstructor public class ExperienceRepositoryImpl implements ExperienceRepositoryCustom { + private static final Logger log = LoggerFactory.getLogger(ExperienceRepositoryImpl.class); private final JPAQueryFactory queryFactory; @Override @@ -100,54 +112,129 @@ public ExperienceCompositeDto findCompositeDtoByIdWithPessimisticLock(Long id, } @Override - public Page searchCompositeDtoByKeyword(String keyword, Language language, - Pageable pageable) { + public Page findSearchDtoByKeywordsUnion(List keywords, + Language language, Pageable pageable) { - List idListContainAllHashtags = getIdListContainAllHashtags(keyword, language); + // experience_id를 가진 게시물의 해시태그가 검색어 키워드 중 몇개를 포함하는지 계산 + List keywordMatchQuery = queryFactory + .select(experience.id, experience.id.count()) + .from(experience) + .leftJoin(hashtag) + .on(hashtag.post.id.eq(experience.id) + .and(hashtag.language.eq(language))) + .innerJoin(hashtag.keyword, QKeyword.keyword) + .where(QKeyword.keyword.content.toLowerCase().trim().in(keywords)) + .groupBy(experience.id) + .fetch(); - List resultDto = queryFactory - .select(new QExperienceCompositeDto( + Map keywordMatchMap = keywordMatchQuery.stream() + .collect(Collectors.toMap( + tuple -> tuple.get(experience.id), // key: experience_id + tuple -> tuple.get(experience.id.count()) // value: 매칭된 키워드 개수 + )); + + List resultDto = queryFactory + .select(new QExperienceSearchDto( experience.id, + experienceTrans.title, imageFile.originUrl, imageFile.thumbnailUrl, - experience.contact, - experience.homepage, - experienceTrans.language, - experienceTrans.title, - experienceTrans.content, - experienceTrans.address, - experienceTrans.addressTag, - experienceTrans.intro, - experienceTrans.details, - experienceTrans.time, - experienceTrans.amenity, - experienceTrans.fee + countMatchingWithKeyword(keywords), // 제목, 내용, 지역정보와 매칭되는 키워드 개수 + experience.createdAt )) .from(experience) .leftJoin(experience.firstImageFile, imageFile) .leftJoin(experience.experienceTrans, experienceTrans) .on(experienceTrans.language.eq(language)) - .where(experienceTrans.title.contains(keyword) - .or(experienceTrans.addressTag.contains(keyword)) - .or(experienceTrans.content.contains(keyword)) - .or(experience.id.in(idListContainAllHashtags))) - .orderBy(experienceTrans.createdAt.desc()) - .offset(pageable.getOffset()) - .limit(pageable.getPageSize()) .fetch(); - JPAQuery countQuery = queryFactory - .select(experience.count()) + // 해시태그 값을 matchedCount에 더해줌 + for (ExperienceSearchDto experienceSearchDto : resultDto) { + Long id = experienceSearchDto.getId(); + experienceSearchDto.addMatchedCount(keywordMatchMap.getOrDefault(id, 0L)); + } + // matchedCount가 0이라면 검색결과에서 제거 + resultDto = resultDto.stream() + .filter(experienceSearchDto -> experienceSearchDto.getMatchedCount() > 0) + .toList(); + + // 매칭된 키워드 수 내림차순, 생성날짜 내림차순 정렬 + List resultList = new ArrayList<>(resultDto); + resultList.sort(Comparator + .comparing(ExperienceSearchDto::getMatchedCount, + Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(ExperienceSearchDto::getCreatedAt, + Comparator.nullsLast(Comparator.reverseOrder()))); + + // 페이징 처리 + int startIdx = pageable.getPageSize() * pageable.getPageNumber(); + int endIdx = Math.min(startIdx + pageable.getPageSize(), resultList.size()); + List finalList = resultList.subList(startIdx, endIdx); + final Long total = Long.valueOf(resultDto.size()); + + return PageableExecutionUtils.getPage(finalList, pageable, () -> total); + } + + @Override + public Page findSearchDtoByKeywordsIntersect(List keywords, + Language language, Pageable pageable) { + + // experience_id를 가진 게시물의 해시태그가 검색어 키워드 중 몇개를 포함하는지 계산 + List keywordMatchQuery = queryFactory + .select(experience.id, experience.id.count()) + .from(experience) + .leftJoin(hashtag) + .on(hashtag.post.id.eq(experience.id) + .and(hashtag.language.eq(language))) + .innerJoin(hashtag.keyword, QKeyword.keyword) + .where(QKeyword.keyword.content.toLowerCase().trim().in(keywords)) + .groupBy(experience.id) + .fetch(); + + Map keywordMatchMap = keywordMatchQuery.stream() + .collect(Collectors.toMap( + tuple -> tuple.get(experience.id), // key: experience_id + tuple -> tuple.get(experience.id.count()) // value: 매칭된 키워드 개수 + )); + + List resultDto = queryFactory + .select(new QExperienceSearchDto( + experience.id, + experienceTrans.title, + imageFile.originUrl, + imageFile.thumbnailUrl, + countMatchingWithKeyword(keywords), // 제목, 내용, 지역정보와 매칭되는 키워드 개수 + experience.createdAt + )) .from(experience) .leftJoin(experience.firstImageFile, imageFile) .leftJoin(experience.experienceTrans, experienceTrans) .on(experienceTrans.language.eq(language)) - .where(experienceTrans.title.contains(keyword) - .or(experienceTrans.addressTag.contains(keyword)) - .or(experienceTrans.content.contains(keyword)) - .or(experience.id.in(idListContainAllHashtags))); + .fetch(); - return PageableExecutionUtils.getPage(resultDto, pageable, countQuery::fetchOne); + // 해시태그 값을 matchedCount에 더해줌 + for (ExperienceSearchDto experienceSearchDto : resultDto) { + Long id = experienceSearchDto.getId(); + experienceSearchDto.addMatchedCount(keywordMatchMap.getOrDefault(id, 0L)); + } + // matchedCount가 키워드 개수와 다르다면 검색결과에서 제거 + resultDto = resultDto.stream() + .filter(experienceSearchDto -> experienceSearchDto.getMatchedCount() >= keywords.size()) + .toList(); + + // 생성날짜 내림차순 정렬 + List resultList = new ArrayList<>(resultDto); + resultList.sort(Comparator + .comparing(ExperienceSearchDto::getCreatedAt, + Comparator.nullsLast(Comparator.reverseOrder()))); + + // 페이징 처리 + int startIdx = pageable.getPageSize() * pageable.getPageNumber(); + int endIdx = Math.min(startIdx + pageable.getPageSize(), resultList.size()); + List finalList = resultList.subList(startIdx, endIdx); + final Long total = Long.valueOf(resultDto.size()); + + return PageableExecutionUtils.getPage(finalList, pageable, () -> total); } @Override @@ -326,7 +413,7 @@ public PopularPostPreviewDto findPostPreviewDtoByLanguageAndId(Language language .fetchOne(); } - private List getIdListContainAllHashtags(String keyword, Language language) { + private List getIdListContainAllHashtags(String keywords, Language language) { return queryFactory .select(experience.id) .from(experience) @@ -334,9 +421,23 @@ private List getIdListContainAllHashtags(String keyword, Language language .on(hashtag.post.id.eq(experience.id) .and(hashtag.category.eq(Category.EXPERIENCE)) .and(hashtag.language.eq(language))) - .where(hashtag.keyword.content.in(splitKeyword(keyword))) + .where(hashtag.keyword.content.toLowerCase().trim().in(keywords)) .groupBy(experience.id) - .having(experience.id.count().eq(splitKeyword(keyword).stream().count())) + .having(experience.id.count().eq(splitKeyword(keywords).stream().count())) + .fetch(); + } + + private List getIdListContainAllHashtags(List keywords, Language language) { + return queryFactory + .select(experience.id) + .from(experience) + .leftJoin(hashtag) + .on(hashtag.post.id.eq(experience.id) + .and(hashtag.category.eq(Category.EXPERIENCE)) + .and(hashtag.language.eq(language))) + .where(hashtag.keyword.content.toLowerCase().trim().in(keywords)) + .groupBy(experience.id) + .having(experience.id.count().eq(keywords.stream().count())) .fetch(); } @@ -367,4 +468,35 @@ private BooleanExpression keywordCondition(List keywordFi return experienceKeyword.experienceTypeKeyword.in(keywordFilterList); } } + + private Expression countMatchingWithKeyword(List keywords) { + return Expressions.asNumber(0L) + .add(countMatchingConditionWithKeyword(experienceTrans.title.toLowerCase().trim(), keywords, + 0)) + .add(countMatchingConditionWithKeyword(experienceTrans.addressTag.toLowerCase().trim(), + keywords, 0)) + .add(countMatchingConditionWithKeyword(experienceTrans.content, keywords, 0)); + } + + private Expression countMatchingConditionWithKeyword(StringExpression condition, + List keywords, int idx) { + if (idx == keywords.size()) { + return Expressions.asNumber(0); + } + + return new CaseBuilder() + .when(condition.contains(keywords.get(idx))) + .then(1) + .otherwise(0) + .add(countMatchingConditionWithKeyword(condition, keywords, idx + 1)); + } + + private BooleanExpression containsAllKeywords(StringExpression condition, List keywords) { + BooleanExpression expression = null; + for (String keyword : keywords) { + BooleanExpression containsKeyword = condition.contains(keyword); + expression = (expression == null) ? containsKeyword : expression.and(containsKeyword); + } + return expression; + } } \ No newline at end of file diff --git a/src/main/java/com/jeju/nanaland/domain/festival/dto/FestivalSearchDto.java b/src/main/java/com/jeju/nanaland/domain/festival/dto/FestivalSearchDto.java new file mode 100644 index 00000000..5e8cc74a --- /dev/null +++ b/src/main/java/com/jeju/nanaland/domain/festival/dto/FestivalSearchDto.java @@ -0,0 +1,22 @@ +package com.jeju.nanaland.domain.festival.dto; + +import com.jeju.nanaland.domain.common.dto.SearchDto; +import com.querydsl.core.annotations.QueryProjection; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class FestivalSearchDto extends SearchDto { + + @QueryProjection + public FestivalSearchDto(Long id, String title, String originUrl, String thumbnailUrl, + Long matchedCount, + LocalDateTime createdAt) { + super(id, title, originUrl, thumbnailUrl, matchedCount, createdAt); + } +} diff --git a/src/main/java/com/jeju/nanaland/domain/festival/repository/FestivalRepositoryCustom.java b/src/main/java/com/jeju/nanaland/domain/festival/repository/FestivalRepositoryCustom.java index 76ac798d..0393a8c1 100644 --- a/src/main/java/com/jeju/nanaland/domain/festival/repository/FestivalRepositoryCustom.java +++ b/src/main/java/com/jeju/nanaland/domain/festival/repository/FestivalRepositoryCustom.java @@ -5,6 +5,7 @@ import com.jeju.nanaland.domain.common.dto.PopularPostPreviewDto; import com.jeju.nanaland.domain.common.dto.PostPreviewDto; import com.jeju.nanaland.domain.festival.dto.FestivalCompositeDto; +import com.jeju.nanaland.domain.festival.dto.FestivalSearchDto; import java.time.LocalDate; import java.util.List; import org.springframework.data.domain.Page; @@ -16,9 +17,6 @@ public interface FestivalRepositoryCustom { FestivalCompositeDto findCompositeDtoByIdWithPessimisticLock(Long id, Language locale); - Page searchCompositeDtoByKeyword(String keyword, Language locale, - Pageable pageable); - Page searchCompositeDtoByOnGoing(Language locale, Pageable pageable, boolean onGoing, List addressTags); @@ -36,4 +34,10 @@ PopularPostPreviewDto findRandomPopularPostPreviewDtoByLanguage(Language languag List excludeIds); PopularPostPreviewDto findPostPreviewDtoByLanguageAndId(Language language, Long postId); + + Page findSearchDtoByKeywordsUnion(List keywords, Language language, + Pageable pageable); + + Page findSearchDtoByKeywordsIntersect(List keywords, + Language language, Pageable pageable); } diff --git a/src/main/java/com/jeju/nanaland/domain/festival/repository/FestivalRepositoryImpl.java b/src/main/java/com/jeju/nanaland/domain/festival/repository/FestivalRepositoryImpl.java index d4d94633..09ce448e 100644 --- a/src/main/java/com/jeju/nanaland/domain/festival/repository/FestivalRepositoryImpl.java +++ b/src/main/java/com/jeju/nanaland/domain/festival/repository/FestivalRepositoryImpl.java @@ -15,15 +15,25 @@ import com.jeju.nanaland.domain.common.dto.QPopularPostPreviewDto; import com.jeju.nanaland.domain.common.dto.QPostPreviewDto; import com.jeju.nanaland.domain.festival.dto.FestivalCompositeDto; +import com.jeju.nanaland.domain.festival.dto.FestivalSearchDto; import com.jeju.nanaland.domain.festival.dto.QFestivalCompositeDto; +import com.jeju.nanaland.domain.festival.dto.QFestivalSearchDto; +import com.jeju.nanaland.domain.hashtag.entity.QKeyword; +import com.querydsl.core.Tuple; +import com.querydsl.core.types.Expression; import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.CaseBuilder; import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.core.types.dsl.StringExpression; import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import jakarta.persistence.LockModeType; import java.time.LocalDate; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -97,59 +107,144 @@ public FestivalCompositeDto findCompositeDtoByIdWithPessimisticLock(Long id, Lan .fetchOne(); } + /** + * 게시물의 제목, 주소태그, 키워드, 해시태그 중 하나라도 겹치는 게시물이 있다면 조회 일치한 수, 생성일자 내림차순 + * + * @param keywords 유저 키워드 리스트 + * @param language 유저 언어 + * @param pageable 페이징 + * @return 검색결과 + */ @Override - public Page searchCompositeDtoByKeyword(String keyword, Language language, - Pageable pageable) { + public Page findSearchDtoByKeywordsUnion(List keywords, + Language language, Pageable pageable) { + // festival_id를 가진 게시물의 해시태그가 검색어 키워드 중 몇개를 포함하는지 계산 + List keywordMatchQuery = queryFactory + .select(festival.id, festival.id.count()) + .from(festival) + .leftJoin(hashtag) + .on(hashtag.post.id.eq(festival.id) + .and(hashtag.language.eq(language))) + .innerJoin(hashtag.keyword, QKeyword.keyword) + .where(QKeyword.keyword.content.toLowerCase().trim().in(keywords)) + .groupBy(festival.id) + .fetch(); - List idListContainAllHashtags = getIdListContainAllHashtags(keyword, language); + Map keywordMatchMap = keywordMatchQuery.stream() + .collect(Collectors.toMap( + tuple -> tuple.get(festival.id), // key: festival_id + tuple -> tuple.get(festival.id.count()) // value: 매칭된 키워드 개수 + )); - List resultDto = queryFactory - .select(new QFestivalCompositeDto( + List resultDto = queryFactory + .select(new QFestivalSearchDto( festival.id, + festivalTrans.title, imageFile.originUrl, imageFile.thumbnailUrl, - festival.contact, - festival.onGoing, - festival.homepage, - festivalTrans.language, - festivalTrans.title, - festivalTrans.content, - festivalTrans.address, - festivalTrans.addressTag, - festivalTrans.time, - festivalTrans.intro, - festivalTrans.fee, - festival.startDate, - festival.endDate, - festival.season + countMatchingWithKeyword(keywords), // 제목, 내용, 지역정보와 매칭되는 키워드 개수 + festival.createdAt )) .from(festival) .leftJoin(festival.firstImageFile, imageFile) .leftJoin(festival.festivalTrans, festivalTrans) .on(festivalTrans.language.eq(language)) - .where(festivalTrans.title.contains(keyword) - .or(festivalTrans.addressTag.contains(keyword)) - .or(festivalTrans.content.contains(keyword)) - .or(festival.id.in(idListContainAllHashtags)) - .and(festival.status.eq(Status.ACTIVE))) - .orderBy(festivalTrans.createdAt.desc()) - .offset(pageable.getOffset()) - .limit(pageable.getPageSize()) .fetch(); - JPAQuery countQuery = queryFactory - .select(festival.count()) + // 해시태그 값을 matchedCount에 더해줌 + for (FestivalSearchDto festivalSearchDto : resultDto) { + Long id = festivalSearchDto.getId(); + festivalSearchDto.addMatchedCount(keywordMatchMap.getOrDefault(id, 0L)); + } + // matchedCount가 0이라면 검색결과에서 제거 + resultDto = resultDto.stream() + .filter(festivalSearchDto -> festivalSearchDto.getMatchedCount() > 0) + .toList(); + + // 매칭된 키워드 수 내림차순, 생성날짜 내림차순 정렬 + List resultList = new ArrayList<>(resultDto); + resultList.sort(Comparator + .comparing(FestivalSearchDto::getMatchedCount, + Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(FestivalSearchDto::getCreatedAt, + Comparator.nullsLast(Comparator.reverseOrder()))); + + // 페이징 처리 + int startIdx = pageable.getPageSize() * pageable.getPageNumber(); + int endIdx = Math.min(startIdx + pageable.getPageSize(), resultList.size()); + List finalList = resultList.subList(startIdx, endIdx); + final Long total = Long.valueOf(resultDto.size()); + + return PageableExecutionUtils.getPage(finalList, pageable, () -> total); + } + + /** + * 게시물의 제목, 주소태그, 키워드, 해시태그와 모두 겹치는 게시물이 있다면 조회 생성일자 내림차순 + * + * @param keywords 유저 키워드 리스트 + * @param language 유저 언어 + * @param pageable 페이징 + * @return 검색결과 + */ + @Override + public Page findSearchDtoByKeywordsIntersect(List keywords, + Language language, Pageable pageable) { + // festival_id를 가진 게시물의 해시태그가 검색어 키워드 중 몇개를 포함하는지 계산 + List keywordMatchQuery = queryFactory + .select(festival.id, festival.id.count()) + .from(festival) + .leftJoin(hashtag) + .on(hashtag.post.id.eq(festival.id) + .and(hashtag.language.eq(language))) + .innerJoin(hashtag.keyword, QKeyword.keyword) + .where(QKeyword.keyword.content.toLowerCase().trim().in(keywords)) + .groupBy(festival.id) + .fetch(); + + Map keywordMatchMap = keywordMatchQuery.stream() + .collect(Collectors.toMap( + tuple -> tuple.get(festival.id), // key: festival_id + tuple -> tuple.get(festival.id.count()) // value: 매칭된 키워드 개수 + )); + + List resultDto = queryFactory + .select(new QFestivalSearchDto( + festival.id, + festivalTrans.title, + imageFile.originUrl, + imageFile.thumbnailUrl, + countMatchingWithKeyword(keywords), // 제목, 내용, 지역정보와 매칭되는 키워드 개수 + festival.createdAt + )) .from(festival) .leftJoin(festival.firstImageFile, imageFile) .leftJoin(festival.festivalTrans, festivalTrans) .on(festivalTrans.language.eq(language)) - .where(festivalTrans.title.contains(keyword) - .or(festivalTrans.addressTag.contains(keyword)) - .or(festivalTrans.content.contains(keyword)) - .or(festival.id.in(idListContainAllHashtags)) - .and(festival.status.eq(Status.ACTIVE))); + .fetch(); - return PageableExecutionUtils.getPage(resultDto, pageable, countQuery::fetchOne); + // 해시태그 값을 matchedCount에 더해줌 + for (FestivalSearchDto festivalSearchDto : resultDto) { + Long id = festivalSearchDto.getId(); + festivalSearchDto.addMatchedCount(keywordMatchMap.getOrDefault(id, 0L)); + } + // matchedCount가 키워드 개수와 다르다면 검색결과에서 제거 + resultDto = resultDto.stream() + .filter(festivalSearchDto -> festivalSearchDto.getMatchedCount() >= keywords.size()) + .toList(); + + // 생성날짜 내림차순 정렬 + List resultList = new ArrayList<>(resultDto); + resultList.sort(Comparator + .comparing(FestivalSearchDto::getCreatedAt, + Comparator.nullsLast(Comparator.reverseOrder()))); + + // 페이징 처리 + int startIdx = pageable.getPageSize() * pageable.getPageNumber(); + int endIdx = Math.min(startIdx + pageable.getPageSize(), resultList.size()); + List finalList = resultList.subList(startIdx, endIdx); + final Long total = Long.valueOf(resultDto.size()); + + return PageableExecutionUtils.getPage(finalList, pageable, () -> total); } private List getIdListContainAllHashtags(String keyword, Language language) { @@ -438,4 +533,40 @@ private BooleanExpression addressTagCondition(Language language, List countMatchingWithKeyword(List keywords) { + return Expressions.asNumber(0L) + .add(countMatchingConditionWithKeyword(festivalTrans.title.toLowerCase().trim(), keywords, + 0)) + .add(countMatchingConditionWithKeyword(festivalTrans.addressTag.toLowerCase().trim(), + keywords, 0)) + .add(countMatchingConditionWithKeyword(festivalTrans.content, keywords, 0)); + } + + /** + * 조건이 키워드를 포함하는지 검사 + * + * @param condition 테이블 컬럼 + * @param keywords 유저 키워드 리스트 + * @param idx 키워드 인덱스 + * @return + */ + private Expression countMatchingConditionWithKeyword(StringExpression condition, + List keywords, int idx) { + if (idx == keywords.size()) { + return Expressions.asNumber(0); + } + + return new CaseBuilder() + .when(condition.contains(keywords.get(idx))) + .then(1) + .otherwise(0) + .add(countMatchingConditionWithKeyword(condition, keywords, idx + 1)); + } } diff --git a/src/main/java/com/jeju/nanaland/domain/market/dto/MarketSearchDto.java b/src/main/java/com/jeju/nanaland/domain/market/dto/MarketSearchDto.java new file mode 100644 index 00000000..bc006450 --- /dev/null +++ b/src/main/java/com/jeju/nanaland/domain/market/dto/MarketSearchDto.java @@ -0,0 +1,22 @@ +package com.jeju.nanaland.domain.market.dto; + +import com.jeju.nanaland.domain.common.dto.SearchDto; +import com.querydsl.core.annotations.QueryProjection; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MarketSearchDto extends SearchDto { + + @QueryProjection + public MarketSearchDto(Long id, String title, String originUrl, String thumbnailUrl, + Long matchedCount, + LocalDateTime createdAt) { + super(id, title, originUrl, thumbnailUrl, matchedCount, createdAt); + } +} diff --git a/src/main/java/com/jeju/nanaland/domain/market/repository/MarketRepositoryCustom.java b/src/main/java/com/jeju/nanaland/domain/market/repository/MarketRepositoryCustom.java index a03c4d08..482a5fb6 100644 --- a/src/main/java/com/jeju/nanaland/domain/market/repository/MarketRepositoryCustom.java +++ b/src/main/java/com/jeju/nanaland/domain/market/repository/MarketRepositoryCustom.java @@ -6,6 +6,7 @@ import com.jeju.nanaland.domain.common.dto.PostPreviewDto; import com.jeju.nanaland.domain.market.dto.MarketCompositeDto; import com.jeju.nanaland.domain.market.dto.MarketResponse.MarketThumbnail; +import com.jeju.nanaland.domain.market.dto.MarketSearchDto; import java.util.List; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -19,9 +20,6 @@ public interface MarketRepositoryCustom { Page findMarketThumbnails(Language locale, List addressTags, Pageable pageable); - Page searchCompositeDtoByKeyword(String keyword, Language locale, - Pageable pageable); - PostPreviewDto findPostPreviewDto(Long postId, Language language); List findAllTop3PopularPostPreviewDtoByLanguage(Language language); @@ -30,4 +28,10 @@ PopularPostPreviewDto findRandomPopularPostPreviewDtoByLanguage(Language languag List excludeIds); PopularPostPreviewDto findPostPreviewDtoByLanguageAndId(Language language, Long postId); + + Page findSearchDtoByKeywordsUnion(List keywords, Language language, + Pageable pageable); + + Page findSearchDtoByKeywordsIntersect(List keywords, + Language language, Pageable pageable); } diff --git a/src/main/java/com/jeju/nanaland/domain/market/repository/MarketRepositoryImpl.java b/src/main/java/com/jeju/nanaland/domain/market/repository/MarketRepositoryImpl.java index ff4bffff..747f72b7 100644 --- a/src/main/java/com/jeju/nanaland/domain/market/repository/MarketRepositoryImpl.java +++ b/src/main/java/com/jeju/nanaland/domain/market/repository/MarketRepositoryImpl.java @@ -13,17 +13,27 @@ import com.jeju.nanaland.domain.common.dto.PostPreviewDto; import com.jeju.nanaland.domain.common.dto.QPopularPostPreviewDto; import com.jeju.nanaland.domain.common.dto.QPostPreviewDto; +import com.jeju.nanaland.domain.hashtag.entity.QKeyword; import com.jeju.nanaland.domain.market.dto.MarketCompositeDto; import com.jeju.nanaland.domain.market.dto.MarketResponse.MarketThumbnail; +import com.jeju.nanaland.domain.market.dto.MarketSearchDto; import com.jeju.nanaland.domain.market.dto.QMarketCompositeDto; import com.jeju.nanaland.domain.market.dto.QMarketResponse_MarketThumbnail; +import com.jeju.nanaland.domain.market.dto.QMarketSearchDto; +import com.querydsl.core.Tuple; +import com.querydsl.core.types.Expression; import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.CaseBuilder; import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.core.types.dsl.StringExpression; import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import jakarta.persistence.LockModeType; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -116,53 +126,144 @@ public Page findMarketThumbnails(Language language, return PageableExecutionUtils.getPage(resultDto, pageable, countQuery::fetchOne); } + /** + * 게시물의 제목, 주소태그, 키워드, 해시태그 중 하나라도 겹치는 게시물이 있다면 조회 일치한 수, 생성일자 내림차순 + * + * @param keywords 유저 키워드 리스트 + * @param language 유저 언어 + * @param pageable 페이징 + * @return 검색결과 + */ @Override - public Page searchCompositeDtoByKeyword(String keyword, Language language, - Pageable pageable) { + public Page findSearchDtoByKeywordsUnion(List keywords, + Language language, Pageable pageable) { + // market_id를 가진 게시물의 해시태그가 검색어 키워드 중 몇개를 포함하는지 계산 + List keywordMatchQuery = queryFactory + .select(market.id, market.id.count()) + .from(market) + .leftJoin(hashtag) + .on(hashtag.post.id.eq(market.id) + .and(hashtag.language.eq(language))) + .innerJoin(hashtag.keyword, QKeyword.keyword) + .where(QKeyword.keyword.content.toLowerCase().trim().in(keywords)) + .groupBy(market.id) + .fetch(); - List idListContainAllHashtags = getIdListContainAllHashtags(keyword, language); + Map keywordMatchMap = keywordMatchQuery.stream() + .collect(Collectors.toMap( + tuple -> tuple.get(market.id), // key: market_id + tuple -> tuple.get(market.id.count()) // value: 매칭된 키워드 개수 + )); - List resultDto = queryFactory - .select(new QMarketCompositeDto( + List resultDto = queryFactory + .select(new QMarketSearchDto( market.id, + marketTrans.title, imageFile.originUrl, imageFile.thumbnailUrl, - market.contact, - market.homepage, - marketTrans.language, - marketTrans.title, - marketTrans.content, - marketTrans.address, - marketTrans.addressTag, - marketTrans.time, - marketTrans.intro, - marketTrans.amenity + countMatchingWithKeyword(keywords), // 제목, 내용, 지역정보와 매칭되는 키워드 개수 + market.createdAt )) .from(market) .leftJoin(market.firstImageFile, imageFile) .leftJoin(market.marketTrans, marketTrans) .on(marketTrans.language.eq(language)) - .where(marketTrans.title.contains(keyword) - .or(marketTrans.addressTag.contains(keyword)) - .or(marketTrans.content.contains(keyword)) - .or(market.id.in(idListContainAllHashtags))) - .orderBy(marketTrans.createdAt.desc()) - .offset(pageable.getOffset()) - .limit(pageable.getPageSize()) .fetch(); - JPAQuery countQuery = queryFactory - .select(market.count()) + // 해시태그 값을 matchedCount에 더해줌 + for (MarketSearchDto marketSearchDto : resultDto) { + Long id = marketSearchDto.getId(); + marketSearchDto.addMatchedCount(keywordMatchMap.getOrDefault(id, 0L)); + } + // matchedCount가 0이라면 검색결과에서 제거 + resultDto = resultDto.stream() + .filter(marketSearchDto -> marketSearchDto.getMatchedCount() > 0) + .toList(); + + // 매칭된 키워드 수 내림차순, 생성날짜 내림차순 정렬 + List resultList = new ArrayList<>(resultDto); + resultList.sort(Comparator + .comparing(MarketSearchDto::getMatchedCount, + Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(MarketSearchDto::getCreatedAt, + Comparator.nullsLast(Comparator.reverseOrder()))); + + // 페이징 처리 + int startIdx = pageable.getPageSize() * pageable.getPageNumber(); + int endIdx = Math.min(startIdx + pageable.getPageSize(), resultList.size()); + List finalList = resultList.subList(startIdx, endIdx); + final Long total = Long.valueOf(resultDto.size()); + + return PageableExecutionUtils.getPage(finalList, pageable, () -> total); + } + + /** + * 게시물의 제목, 주소태그, 키워드, 해시태그와 모두 겹치는 게시물이 있다면 조회 생성일자 내림차순 + * + * @param keywords 유저 키워드 리스트 + * @param language 유저 언어 + * @param pageable 페이징 + * @return 검색결과 + */ + @Override + public Page findSearchDtoByKeywordsIntersect(List keywords, + Language language, Pageable pageable) { + // market_id를 가진 게시물의 해시태그가 검색어 키워드 중 몇개를 포함하는지 계산 + List keywordMatchQuery = queryFactory + .select(market.id, market.id.count()) + .from(market) + .leftJoin(hashtag) + .on(hashtag.post.id.eq(market.id) + .and(hashtag.language.eq(language))) + .innerJoin(hashtag.keyword, QKeyword.keyword) + .where(QKeyword.keyword.content.toLowerCase().trim().in(keywords)) + .groupBy(market.id) + .fetch(); + + Map keywordMatchMap = keywordMatchQuery.stream() + .collect(Collectors.toMap( + tuple -> tuple.get(market.id), // key: market_id + tuple -> tuple.get(market.id.count()) // value: 매칭된 키워드 개수 + )); + + List resultDto = queryFactory + .select(new QMarketSearchDto( + market.id, + marketTrans.title, + imageFile.originUrl, + imageFile.thumbnailUrl, + countMatchingWithKeyword(keywords), // 제목, 내용, 지역정보와 매칭되는 키워드 개수 + market.createdAt + )) .from(market) .leftJoin(market.firstImageFile, imageFile) .leftJoin(market.marketTrans, marketTrans) .on(marketTrans.language.eq(language)) - .where(marketTrans.title.contains(keyword) - .or(marketTrans.addressTag.contains(keyword)) - .or(marketTrans.content.contains(keyword)) - .or(market.id.in(idListContainAllHashtags))); + .fetch(); - return PageableExecutionUtils.getPage(resultDto, pageable, countQuery::fetchOne); + // 해시태그 값을 matchedCount에 더해줌 + for (MarketSearchDto marketSearchDto : resultDto) { + Long id = marketSearchDto.getId(); + marketSearchDto.addMatchedCount(keywordMatchMap.getOrDefault(id, 0L)); + } + // matchedCount가 키워드 개수와 다르다면 검색결과에서 제거 + resultDto = resultDto.stream() + .filter(marketSearchDto -> marketSearchDto.getMatchedCount() >= keywords.size()) + .toList(); + + // 생성날짜 내림차순 정렬 + List resultList = new ArrayList<>(resultDto); + resultList.sort(Comparator + .comparing(MarketSearchDto::getCreatedAt, + Comparator.nullsLast(Comparator.reverseOrder()))); + + // 페이징 처리 + int startIdx = pageable.getPageSize() * pageable.getPageNumber(); + int endIdx = Math.min(startIdx + pageable.getPageSize(), resultList.size()); + List finalList = resultList.subList(startIdx, endIdx); + final Long total = Long.valueOf(resultDto.size()); + + return PageableExecutionUtils.getPage(finalList, pageable, () -> total); } @Override @@ -286,5 +387,39 @@ private BooleanExpression addressTagCondition(Language language, List countMatchingWithKeyword(List keywords) { + return Expressions.asNumber(0L) + .add(countMatchingConditionWithKeyword(marketTrans.title.toLowerCase().trim(), keywords, + 0)) + .add(countMatchingConditionWithKeyword(marketTrans.addressTag.toLowerCase().trim(), + keywords, 0)) + .add(countMatchingConditionWithKeyword(marketTrans.content, keywords, 0)); + } + + /** + * 조건이 키워드를 포함하는지 검사 + * + * @param condition 테이블 컬럼 + * @param keywords 유저 키워드 리스트 + * @param idx 키워드 인덱스 + * @return + */ + private Expression countMatchingConditionWithKeyword(StringExpression condition, + List keywords, int idx) { + if (idx == keywords.size()) { + return Expressions.asNumber(0); + } + return new CaseBuilder() + .when(condition.contains(keywords.get(idx))) + .then(1) + .otherwise(0) + .add(countMatchingConditionWithKeyword(condition, keywords, idx + 1)); + } } diff --git a/src/main/java/com/jeju/nanaland/domain/nana/dto/NanaSearchDto.java b/src/main/java/com/jeju/nanaland/domain/nana/dto/NanaSearchDto.java new file mode 100644 index 00000000..cebf74e6 --- /dev/null +++ b/src/main/java/com/jeju/nanaland/domain/nana/dto/NanaSearchDto.java @@ -0,0 +1,24 @@ +package com.jeju.nanaland.domain.nana.dto; + +import com.jeju.nanaland.domain.common.dto.SearchDto; +import com.querydsl.core.annotations.QueryProjection; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class NanaSearchDto extends SearchDto { + + @QueryProjection + public NanaSearchDto(Long id, String title, String originUrl, String thumbnailUrl, + Long matchedCount, + LocalDateTime createdAt) { + super(id, title, originUrl, thumbnailUrl, matchedCount, createdAt); + } +} diff --git a/src/main/java/com/jeju/nanaland/domain/nana/repository/NanaRepositoryCustom.java b/src/main/java/com/jeju/nanaland/domain/nana/repository/NanaRepositoryCustom.java index 57c240d2..ca83d9c7 100644 --- a/src/main/java/com/jeju/nanaland/domain/nana/repository/NanaRepositoryCustom.java +++ b/src/main/java/com/jeju/nanaland/domain/nana/repository/NanaRepositoryCustom.java @@ -4,6 +4,7 @@ import com.jeju.nanaland.domain.common.dto.PostPreviewDto; import com.jeju.nanaland.domain.nana.dto.NanaResponse.NanaThumbnailPost; import com.jeju.nanaland.domain.nana.dto.NanaResponse.PreviewDto; +import com.jeju.nanaland.domain.nana.dto.NanaSearchDto; import java.util.List; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -25,4 +26,10 @@ Page searchNanaThumbnailDtoByKeyword(String keyword, Language locale NanaThumbnailPost findNanaThumbnailPostDto(Long id, Language locale); PostPreviewDto findPostPreviewDto(Long postId, Language language); + + Page findSearchDtoByKeywordsUnion(List keywords, Language language, + Pageable pageable); + + Page findSearchDtoByKeywordsIntersect(List keywords, + Language language, Pageable pageable); } diff --git a/src/main/java/com/jeju/nanaland/domain/nana/repository/NanaRepositoryImpl.java b/src/main/java/com/jeju/nanaland/domain/nana/repository/NanaRepositoryImpl.java index c7c608fa..16c18073 100644 --- a/src/main/java/com/jeju/nanaland/domain/nana/repository/NanaRepositoryImpl.java +++ b/src/main/java/com/jeju/nanaland/domain/nana/repository/NanaRepositoryImpl.java @@ -10,14 +10,25 @@ import com.jeju.nanaland.domain.common.data.Language; import com.jeju.nanaland.domain.common.dto.PostPreviewDto; import com.jeju.nanaland.domain.common.dto.QPostPreviewDto; +import com.jeju.nanaland.domain.hashtag.entity.QKeyword; import com.jeju.nanaland.domain.nana.dto.NanaResponse.NanaThumbnailPost; import com.jeju.nanaland.domain.nana.dto.NanaResponse.PreviewDto; +import com.jeju.nanaland.domain.nana.dto.NanaSearchDto; import com.jeju.nanaland.domain.nana.dto.QNanaResponse_NanaThumbnailPost; import com.jeju.nanaland.domain.nana.dto.QNanaResponse_PreviewDto; +import com.jeju.nanaland.domain.nana.dto.QNanaSearchDto; +import com.querydsl.core.Tuple; +import com.querydsl.core.types.Expression; +import com.querydsl.core.types.dsl.CaseBuilder; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.core.types.dsl.StringExpression; import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -143,6 +154,155 @@ public Page searchNanaThumbnailDtoByKeyword(String keyword, Language return PageableExecutionUtils.getPage(resultDto, pageable, countQuery::fetchOne); } + /** + * 게시물의 제목, 주소태그, 키워드, 해시태그 중 하나라도 겹치는 게시물이 있다면 조회 일치한 수, 생성일자 내림차순 + * + * @param keywords 유저 키워드 리스트 + * @param language 유저 언어 + * @param pageable 페이징 + * @return 검색결과 + */ + @Override + public Page findSearchDtoByKeywordsUnion(List keywords, Language language, + Pageable pageable) { + // nana_id 별로 nana_content_id가 가진 해시태그가 검색어 키워드 중 몇개를 포함하는지 계산 + List keywordMatchQuery = queryFactory + .select(nana.id, nana.id.count()) + .from(nana) + .leftJoin(nanaTitle).on(nanaTitle.nana.eq(nana).and(nanaTitle.language.eq(language))) + .leftJoin(nanaContent).on(nanaContent.nanaTitle.eq(nanaTitle)) + .leftJoin(hashtag) + .on(hashtag.post.id.eq(nanaContent.id) + .and(hashtag.language.eq(language))) + .innerJoin(hashtag.keyword, QKeyword.keyword) + .where(QKeyword.keyword.content.toLowerCase().trim().in(keywords)) + .groupBy(nana.id) + .fetch(); + + Map keywordMatchMap = keywordMatchQuery.stream() + .collect(Collectors.toMap( + tuple -> tuple.get(nana.id), // key: nana_id + tuple -> tuple.get(nana.id.count()) // value: 매칭된 키워드 개수 + )); + + List resultDto = queryFactory + .select(new QNanaSearchDto( + nana.id, + nanaTitle.heading, + imageFile.originUrl, + imageFile.thumbnailUrl, + countMatchingWithKeyword(keywords), // 제목, 내용, 지역정보와 매칭되는 키워드 개수 + nana.createdAt + )) + .from(nana) + .leftJoin(nana.firstImageFile, imageFile) + .leftJoin(nanaTitle).on(nanaTitle.nana.eq(nana).and(nanaTitle.language.eq(language))) + .leftJoin(nanaContent).on(nanaContent.nanaTitle.eq(nanaTitle)) + .on(nanaTitle.language.eq(language)) + .fetch(); + + // 해시태그 값을 matchedCount에 더해줌 + for (NanaSearchDto nanaSearchDto : resultDto) { + Long id = nanaSearchDto.getId(); + nanaSearchDto.addMatchedCount(keywordMatchMap.getOrDefault(id, 0L)); + } + // matchedCount가 0이라면 검색결과에서 제거 + resultDto = resultDto.stream() + .filter(nanaSearchDto -> nanaSearchDto.getMatchedCount() > 0) + .toList(); + + // 매칭된 키워드 수 내림차순, 생성날짜 내림차순 정렬 + List resultList = new ArrayList<>(resultDto); + resultList.sort(Comparator + .comparing(NanaSearchDto::getMatchedCount, + Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(NanaSearchDto::getCreatedAt, + Comparator.nullsLast(Comparator.reverseOrder()))); + + // 페이징 처리 + int startIdx = pageable.getPageSize() * pageable.getPageNumber(); + int endIdx = Math.min(startIdx + pageable.getPageSize(), resultList.size()); + List finalList = resultList.subList(startIdx, endIdx); + final Long total = Long.valueOf(resultDto.size()); + + return PageableExecutionUtils.getPage(finalList, pageable, () -> total); + + } + + /** + * 게시물의 제목, 주소태그, 키워드, 해시태그와 모두 겹치는 게시물이 있다면 조회 생성일자 내림차순 + * + * @param keywords 유저 키워드 리스트 + * @param language 유저 언어 + * @param pageable 페이징 + * @return 검색결과 + */ + @Override + public Page findSearchDtoByKeywordsIntersect(List keywords, + Language language, + Pageable pageable) { + // nana_id 별로 nana_content_id가 가진 해시태그가 검색어 키워드 중 몇개를 포함하는지 계산 + List keywordMatchQuery = queryFactory + .select(nana.id, nana.id.count()) + .from(nana) + .leftJoin(nanaTitle).on(nanaTitle.nana.eq(nana).and(nanaTitle.language.eq(language))) + .leftJoin(nanaContent).on(nanaContent.nanaTitle.eq(nanaTitle)) + .leftJoin(hashtag) + .on(hashtag.post.id.eq(nanaContent.id) + .and(hashtag.language.eq(language))) + .innerJoin(hashtag.keyword, QKeyword.keyword) + .where(QKeyword.keyword.content.toLowerCase().trim().in(keywords)) + .groupBy(nana.id) + .fetch(); + + Map keywordMatchMap = keywordMatchQuery.stream() + .collect(Collectors.toMap( + tuple -> tuple.get(nana.id), // key: nana_id + tuple -> tuple.get(nana.id.count()) // value: 매칭된 키워드 개수 + )); + + List resultDto = queryFactory + .select(new QNanaSearchDto( + nana.id, + nanaTitle.heading, + imageFile.originUrl, + imageFile.thumbnailUrl, + countMatchingWithKeyword(keywords), // 제목, 내용, 지역정보와 매칭되는 키워드 개수 + nana.createdAt + )) + .from(nana) + .leftJoin(nana.firstImageFile, imageFile) + .leftJoin(nanaTitle).on(nanaTitle.nana.eq(nana).and(nanaTitle.language.eq(language))) + .leftJoin(nanaContent).on(nanaContent.nanaTitle.eq(nanaTitle)) + .on(nanaTitle.language.eq(language)) + .fetch(); + + // 해시태그 값을 matchedCount에 더해줌 + for (NanaSearchDto nanaSearchDto : resultDto) { + Long id = nanaSearchDto.getId(); + nanaSearchDto.addMatchedCount(keywordMatchMap.getOrDefault(id, 0L)); + } + // matchedCount가 키워드 개수와 다르다면 검색결과에서 제거 + resultDto = resultDto.stream() + .filter(nanaSearchDto -> nanaSearchDto.getMatchedCount() >= keywords.size()) + .toList(); + + // 생성날짜 내림차순 정렬 + List resultList = new ArrayList<>(resultDto); + resultList.sort(Comparator + .comparing(NanaSearchDto::getCreatedAt, + Comparator.nullsLast(Comparator.reverseOrder()))); + + // 페이징 처리 + int startIdx = pageable.getPageSize() * pageable.getPageNumber(); + int endIdx = Math.min(startIdx + pageable.getPageSize(), resultList.size()); + List finalList = resultList.subList(startIdx, endIdx); + final Long total = Long.valueOf(resultDto.size()); + + return PageableExecutionUtils.getPage(finalList, pageable, () -> total); + + } + @Override public PostPreviewDto findPostPreviewDto(Long postId, Language language) { return queryFactory @@ -203,4 +363,38 @@ private List splitKeyword(String keyword) { } return tokenList; } + + /** + * 제목, 주소태그, 내용과 일치하는 키워드 개수 카운팅 + * + * @param keywords 키워드 + * @return 키워드를 포함하는 조건 개수 + */ + private Expression countMatchingWithKeyword(List keywords) { + return Expressions.asNumber(0L) + .add(countMatchingConditionWithKeyword(nanaTitle.heading.toLowerCase().trim(), keywords, + 0)) + .add(countMatchingConditionWithKeyword(nanaContent.content, keywords, 0)); + } + + /** + * 조건이 키워드를 포함하는지 검사 + * + * @param condition 테이블 컬럼 + * @param keywords 유저 키워드 리스트 + * @param idx 키워드 인덱스 + * @return + */ + private Expression countMatchingConditionWithKeyword(StringExpression condition, + List keywords, int idx) { + if (idx == keywords.size()) { + return Expressions.asNumber(0); + } + + return new CaseBuilder() + .when(condition.contains(keywords.get(idx))) + .then(1) + .otherwise(0) + .add(countMatchingConditionWithKeyword(condition, keywords, idx + 1)); + } } diff --git a/src/main/java/com/jeju/nanaland/domain/nature/dto/NatureSearchDto.java b/src/main/java/com/jeju/nanaland/domain/nature/dto/NatureSearchDto.java new file mode 100644 index 00000000..2088d1b6 --- /dev/null +++ b/src/main/java/com/jeju/nanaland/domain/nature/dto/NatureSearchDto.java @@ -0,0 +1,22 @@ +package com.jeju.nanaland.domain.nature.dto; + +import com.jeju.nanaland.domain.common.dto.SearchDto; +import com.querydsl.core.annotations.QueryProjection; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class NatureSearchDto extends SearchDto { + + @QueryProjection + public NatureSearchDto(Long id, String title, String originUrl, String thumbnailUrl, + Long matchedCount, + LocalDateTime createdAt) { + super(id, title, originUrl, thumbnailUrl, matchedCount, createdAt); + } +} diff --git a/src/main/java/com/jeju/nanaland/domain/nature/repository/NatureRepositoryCustom.java b/src/main/java/com/jeju/nanaland/domain/nature/repository/NatureRepositoryCustom.java index 9e68776e..c95fe0fd 100644 --- a/src/main/java/com/jeju/nanaland/domain/nature/repository/NatureRepositoryCustom.java +++ b/src/main/java/com/jeju/nanaland/domain/nature/repository/NatureRepositoryCustom.java @@ -6,6 +6,7 @@ import com.jeju.nanaland.domain.common.dto.PostPreviewDto; import com.jeju.nanaland.domain.nature.dto.NatureCompositeDto; import com.jeju.nanaland.domain.nature.dto.NatureResponse.PreviewDto; +import com.jeju.nanaland.domain.nature.dto.NatureSearchDto; import java.util.List; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -16,9 +17,6 @@ public interface NatureRepositoryCustom { NatureCompositeDto findNatureCompositeDtoWithPessimisticLock(Long id, Language locale); - Page searchCompositeDtoByKeyword(String keyword, Language locale, - Pageable pageable); - Page findAllNaturePreviewDtoOrderByPriorityAndCreatedAtDesc(Language locale, List addressTags, String keyword, Pageable pageable); @@ -31,4 +29,10 @@ PopularPostPreviewDto findRandomPopularPostPreviewDtoByLanguage(Language languag List excludeIds); PopularPostPreviewDto findPostPreviewDtoByLanguageAndId(Language language, Long postId); + + Page findSearchDtoByKeywordsUnion(List keywords, Language language, + Pageable pageable); + + Page findSearchDtoByKeywordsIntersect(List keywords, + Language language, Pageable pageable); } diff --git a/src/main/java/com/jeju/nanaland/domain/nature/repository/NatureRepositoryImpl.java b/src/main/java/com/jeju/nanaland/domain/nature/repository/NatureRepositoryImpl.java index 50e8d583..bacaf5c8 100644 --- a/src/main/java/com/jeju/nanaland/domain/nature/repository/NatureRepositoryImpl.java +++ b/src/main/java/com/jeju/nanaland/domain/nature/repository/NatureRepositoryImpl.java @@ -13,17 +13,27 @@ import com.jeju.nanaland.domain.common.dto.PostPreviewDto; import com.jeju.nanaland.domain.common.dto.QPopularPostPreviewDto; import com.jeju.nanaland.domain.common.dto.QPostPreviewDto; +import com.jeju.nanaland.domain.hashtag.entity.QKeyword; import com.jeju.nanaland.domain.nature.dto.NatureCompositeDto; import com.jeju.nanaland.domain.nature.dto.NatureResponse; +import com.jeju.nanaland.domain.nature.dto.NatureSearchDto; import com.jeju.nanaland.domain.nature.dto.QNatureCompositeDto; import com.jeju.nanaland.domain.nature.dto.QNatureResponse_PreviewDto; +import com.jeju.nanaland.domain.nature.dto.QNatureSearchDto; +import com.querydsl.core.Tuple; +import com.querydsl.core.types.Expression; import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.CaseBuilder; import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.core.types.dsl.StringExpression; import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import jakarta.persistence.LockModeType; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -100,61 +110,143 @@ public NatureCompositeDto findNatureCompositeDtoWithPessimisticLock(Long natureI } /** - * 7대 자연 검색 페이징 조회 + * 게시물의 제목, 주소태그, 키워드, 해시태그 중 하나라도 겹치는 게시물이 있다면 조회 일치한 수, 생성일자 내림차순 * - * @param keyword 검색어 - * @param language 언어 - * @param pageable 페이징 정보 - * @return 7대 자연 검색 페이징 정보 + * @param keywords 유저 키워드 리스트 + * @param language 유저 언어 + * @param pageable 페이징 + * @return 검색결과 */ @Override - public Page searchCompositeDtoByKeyword(String keyword, Language language, - Pageable pageable) { + public Page findSearchDtoByKeywordsUnion(List keywords, + Language language, Pageable pageable) { + // nature_id를 가진 게시물의 해시태그가 검색어 키워드 중 몇개를 포함하는지 계산 + List keywordMatchQuery = queryFactory + .select(nature.id, nature.id.count()) + .from(nature) + .leftJoin(hashtag) + .on(hashtag.post.id.eq(nature.id) + .and(hashtag.language.eq(language))) + .innerJoin(hashtag.keyword, QKeyword.keyword) + .where(QKeyword.keyword.content.toLowerCase().trim().in(keywords)) + .groupBy(nature.id) + .fetch(); - List idListContainAllHashtags = getIdListContainAllHashtags(keyword, language); + Map keywordMatchMap = keywordMatchQuery.stream() + .collect(Collectors.toMap( + tuple -> tuple.get(nature.id), // key: nature_id + tuple -> tuple.get(nature.id.count()) // value: 매칭된 키워드 개수 + )); - List resultDto = queryFactory - .select(new QNatureCompositeDto( + List resultDto = queryFactory + .select(new QNatureSearchDto( nature.id, + natureTrans.title, imageFile.originUrl, imageFile.thumbnailUrl, - nature.contact, - natureTrans.language, - natureTrans.title, - natureTrans.content, - natureTrans.address, - natureTrans.addressTag, - natureTrans.intro, - natureTrans.details, - natureTrans.time, - natureTrans.amenity, - natureTrans.fee + countMatchingWithKeyword(keywords), // 제목, 내용, 지역정보와 매칭되는 키워드 개수 + nature.createdAt )) .from(nature) .leftJoin(nature.firstImageFile, imageFile) .leftJoin(nature.natureTrans, natureTrans) .on(natureTrans.language.eq(language)) - .where(natureTrans.title.contains(keyword) - .or(natureTrans.addressTag.contains(keyword)) - .or(natureTrans.content.contains(keyword)) - .or(nature.id.in(idListContainAllHashtags))) - .orderBy(natureTrans.createdAt.desc()) - .offset(pageable.getOffset()) - .limit(pageable.getPageSize()) .fetch(); - JPAQuery countQuery = queryFactory - .select(nature.countDistinct()) + // 해시태그 값을 matchedCount에 더해줌 + for (NatureSearchDto natureSearchDto : resultDto) { + Long id = natureSearchDto.getId(); + natureSearchDto.addMatchedCount(keywordMatchMap.getOrDefault(id, 0L)); + } + // matchedCount가 0이라면 검색결과에서 제거 + resultDto = resultDto.stream() + .filter(natureSearchDto -> natureSearchDto.getMatchedCount() > 0) + .toList(); + + // 매칭된 키워드 수 내림차순, 생성날짜 내림차순 정렬 + List resultList = new ArrayList<>(resultDto); + resultList.sort(Comparator + .comparing(NatureSearchDto::getMatchedCount, + Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(NatureSearchDto::getCreatedAt, + Comparator.nullsLast(Comparator.reverseOrder()))); + + // 페이징 처리 + int startIdx = pageable.getPageSize() * pageable.getPageNumber(); + int endIdx = Math.min(startIdx + pageable.getPageSize(), resultList.size()); + List finalList = resultList.subList(startIdx, endIdx); + final Long total = Long.valueOf(resultDto.size()); + + return PageableExecutionUtils.getPage(finalList, pageable, () -> total); + } + + /** + * 게시물의 제목, 주소태그, 키워드, 해시태그와 모두 겹치는 게시물이 있다면 조회 생성일자 내림차순 + * + * @param keywords 유저 키워드 리스트 + * @param language 유저 언어 + * @param pageable 페이징 + * @return 검색결과 + */ + @Override + public Page findSearchDtoByKeywordsIntersect(List keywords, + Language language, Pageable pageable) { + // experience_id를 가진 게시물의 해시태그가 검색어 키워드 중 몇개를 포함하는지 계산 + List keywordMatchQuery = queryFactory + .select(nature.id, nature.id.count()) + .from(nature) + .leftJoin(hashtag) + .on(hashtag.post.id.eq(nature.id) + .and(hashtag.language.eq(language))) + .innerJoin(hashtag.keyword, QKeyword.keyword) + .where(QKeyword.keyword.content.toLowerCase().trim().in(keywords)) + .groupBy(nature.id) + .fetch(); + + Map keywordMatchMap = keywordMatchQuery.stream() + .collect(Collectors.toMap( + tuple -> tuple.get(nature.id), // key: nature_id + tuple -> tuple.get(nature.id.count()) // value: 매칭된 키워드 개수 + )); + + List resultDto = queryFactory + .select(new QNatureSearchDto( + nature.id, + natureTrans.title, + imageFile.originUrl, + imageFile.thumbnailUrl, + countMatchingWithKeyword(keywords), // 제목, 내용, 지역정보와 매칭되는 키워드 개수 + nature.createdAt + )) .from(nature) .leftJoin(nature.firstImageFile, imageFile) .leftJoin(nature.natureTrans, natureTrans) .on(natureTrans.language.eq(language)) - .where(natureTrans.title.contains(keyword) - .or(natureTrans.addressTag.contains(keyword)) - .or(natureTrans.content.contains(keyword)) - .or(nature.id.in(idListContainAllHashtags))); + .fetch(); - return PageableExecutionUtils.getPage(resultDto, pageable, countQuery::fetchOne); + // 해시태그 값을 matchedCount에 더해줌 + for (NatureSearchDto natureSearchDto : resultDto) { + Long id = natureSearchDto.getId(); + natureSearchDto.addMatchedCount(keywordMatchMap.getOrDefault(id, 0L)); + } + // matchedCount가 키워드 개수와 다르다면 검색결과에서 제거 + resultDto = resultDto.stream() + .filter(natureSearchDto -> natureSearchDto.getMatchedCount() >= keywords.size()) + .toList(); + + // 생성날짜 내림차순 정렬 + List resultList = new ArrayList<>(resultDto); + resultList.sort(Comparator + .comparing(NatureSearchDto::getCreatedAt, + Comparator.nullsLast(Comparator.reverseOrder()))); + + // 페이징 처리 + int startIdx = pageable.getPageSize() * pageable.getPageNumber(); + int endIdx = Math.min(startIdx + pageable.getPageSize(), resultList.size()); + List finalList = resultList.subList(startIdx, endIdx); + final Long total = Long.valueOf(resultDto.size()); + + return PageableExecutionUtils.getPage(finalList, pageable, () -> total); } /** @@ -346,4 +438,40 @@ private List splitKeyword(String keyword) { } return tokenList; } + + /** + * 제목, 주소태그, 내용과 일치하는 키워드 개수 카운팅 + * + * @param keywords 키워드 + * @return 키워드를 포함하는 조건 개수 + */ + private Expression countMatchingWithKeyword(List keywords) { + return Expressions.asNumber(0L) + .add(countMatchingConditionWithKeyword(natureTrans.title.toLowerCase().trim(), keywords, + 0)) + .add(countMatchingConditionWithKeyword(natureTrans.addressTag.toLowerCase().trim(), + keywords, 0)) + .add(countMatchingConditionWithKeyword(natureTrans.content, keywords, 0)); + } + + /** + * 조건이 키워드를 포함하는지 검사 + * + * @param condition 테이블 컬럼 + * @param keywords 유저 키워드 리스트 + * @param idx 키워드 인덱스 + * @return + */ + private Expression countMatchingConditionWithKeyword(StringExpression condition, + List keywords, int idx) { + if (idx == keywords.size()) { + return Expressions.asNumber(0); + } + + return new CaseBuilder() + .when(condition.contains(keywords.get(idx))) + .then(1) + .otherwise(0) + .add(countMatchingConditionWithKeyword(condition, keywords, idx + 1)); + } } diff --git a/src/main/java/com/jeju/nanaland/domain/restaurant/dto/RestaurantSearchDto.java b/src/main/java/com/jeju/nanaland/domain/restaurant/dto/RestaurantSearchDto.java new file mode 100644 index 00000000..b1ab2ae7 --- /dev/null +++ b/src/main/java/com/jeju/nanaland/domain/restaurant/dto/RestaurantSearchDto.java @@ -0,0 +1,22 @@ +package com.jeju.nanaland.domain.restaurant.dto; + +import com.jeju.nanaland.domain.common.dto.SearchDto; +import com.querydsl.core.annotations.QueryProjection; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RestaurantSearchDto extends SearchDto { + + @QueryProjection + public RestaurantSearchDto(Long id, String title, String originUrl, String thumbnailUrl, + Long matchedCount, + LocalDateTime createdAt) { + super(id, title, originUrl, thumbnailUrl, matchedCount, createdAt); + } +} diff --git a/src/main/java/com/jeju/nanaland/domain/restaurant/repository/RestaurantRepositoryCustom.java b/src/main/java/com/jeju/nanaland/domain/restaurant/repository/RestaurantRepositoryCustom.java index 9ca38433..d0a9e86c 100644 --- a/src/main/java/com/jeju/nanaland/domain/restaurant/repository/RestaurantRepositoryCustom.java +++ b/src/main/java/com/jeju/nanaland/domain/restaurant/repository/RestaurantRepositoryCustom.java @@ -7,6 +7,7 @@ import com.jeju.nanaland.domain.restaurant.dto.RestaurantCompositeDto; import com.jeju.nanaland.domain.restaurant.dto.RestaurantResponse.RestaurantMenuDto; import com.jeju.nanaland.domain.restaurant.dto.RestaurantResponse.RestaurantThumbnail; +import com.jeju.nanaland.domain.restaurant.dto.RestaurantSearchDto; import com.jeju.nanaland.domain.restaurant.entity.enums.RestaurantTypeKeyword; import com.jeju.nanaland.domain.review.dto.ReviewResponse.SearchPostForReviewDto; import java.util.List; @@ -31,9 +32,6 @@ Page findRestaurantThumbnails(Language language, List getRestaurantMenuListWithPessimisticLock(Long postId, Language language); - Page searchCompositeDtoByKeyword(String keyword, Language language, - Pageable pageable); - List findAllSearchPostForReviewDtoByLanguage(Language language); List findAllIds(); @@ -46,4 +44,10 @@ PopularPostPreviewDto findRandomPopularPostPreviewDtoByLanguage(Language languag List excludeIds); PopularPostPreviewDto findPostPreviewDtoByLanguageAndId(Language language, Long postId); + + Page findSearchDtoByKeywordsUnion(List keywords, Language language, + Pageable pageable); + + Page findSearchDtoByKeywordsIntersect(List keywords, + Language language, Pageable pageable); } diff --git a/src/main/java/com/jeju/nanaland/domain/restaurant/repository/RestaurantRepositoryImpl.java b/src/main/java/com/jeju/nanaland/domain/restaurant/repository/RestaurantRepositoryImpl.java index 76483460..09760f93 100644 --- a/src/main/java/com/jeju/nanaland/domain/restaurant/repository/RestaurantRepositoryImpl.java +++ b/src/main/java/com/jeju/nanaland/domain/restaurant/repository/RestaurantRepositoryImpl.java @@ -15,26 +15,35 @@ import com.jeju.nanaland.domain.common.dto.PostPreviewDto; import com.jeju.nanaland.domain.common.dto.QPopularPostPreviewDto; import com.jeju.nanaland.domain.common.dto.QPostPreviewDto; +import com.jeju.nanaland.domain.hashtag.entity.QKeyword; import com.jeju.nanaland.domain.restaurant.dto.QRestaurantCompositeDto; import com.jeju.nanaland.domain.restaurant.dto.QRestaurantResponse_RestaurantMenuDto; import com.jeju.nanaland.domain.restaurant.dto.QRestaurantResponse_RestaurantThumbnail; +import com.jeju.nanaland.domain.restaurant.dto.QRestaurantSearchDto; import com.jeju.nanaland.domain.restaurant.dto.RestaurantCompositeDto; import com.jeju.nanaland.domain.restaurant.dto.RestaurantResponse.RestaurantMenuDto; import com.jeju.nanaland.domain.restaurant.dto.RestaurantResponse.RestaurantThumbnail; +import com.jeju.nanaland.domain.restaurant.dto.RestaurantSearchDto; import com.jeju.nanaland.domain.restaurant.entity.enums.RestaurantTypeKeyword; import com.jeju.nanaland.domain.review.dto.QReviewResponse_SearchPostForReviewDto; import com.jeju.nanaland.domain.review.dto.ReviewResponse.SearchPostForReviewDto; +import com.querydsl.core.Tuple; import com.querydsl.core.group.GroupBy; +import com.querydsl.core.types.Expression; import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.CaseBuilder; import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.core.types.dsl.StringExpression; import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import jakarta.persistence.LockModeType; import java.util.ArrayList; import java.util.Collections; +import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -195,54 +204,144 @@ public List getRestaurantMenuListWithPessimisticLock(Long pos .fetch(); } + /** + * 게시물의 제목, 주소태그, 키워드, 해시태그 중 하나라도 겹치는 게시물이 있다면 조회 일치한 수, 생성일자 내림차순 + * + * @param keywords 유저 키워드 리스트 + * @param language 유저 언어 + * @param pageable 페이징 + * @return 검색결과 + */ @Override - public Page searchCompositeDtoByKeyword(String keyword, Language language, - Pageable pageable) { - List idListContainAllHashtags = getIdListContainAllHashtags(keyword, language); - List idListContainHashtag = getIdListContainHashtag(keyword, language); + public Page findSearchDtoByKeywordsUnion(List keywords, + Language language, Pageable pageable) { + // restaurant_id를 가진 게시물의 해시태그가 검색어 키워드 중 몇개를 포함하는지 계산 + List keywordMatchQuery = queryFactory + .select(restaurant.id, restaurant.id.count()) + .from(restaurant) + .leftJoin(hashtag) + .on(hashtag.post.id.eq(restaurant.id) + .and(hashtag.language.eq(language))) + .innerJoin(hashtag.keyword, QKeyword.keyword) + .where(QKeyword.keyword.content.toLowerCase().trim().in(keywords)) + .groupBy(restaurant.id) + .fetch(); - List resultDto = queryFactory - .select(new QRestaurantCompositeDto( + Map keywordMatchMap = keywordMatchQuery.stream() + .collect(Collectors.toMap( + tuple -> tuple.get(restaurant.id), // key: restaurant_id + tuple -> tuple.get(restaurant.id.count()) // value: 매칭된 키워드 개수 + )); + + List resultDto = queryFactory + .select(new QRestaurantSearchDto( restaurant.id, + restaurantTrans.title, imageFile.originUrl, imageFile.thumbnailUrl, - restaurant.contact, - restaurantTrans.language, - restaurantTrans.title, - restaurantTrans.content, - restaurantTrans.address, - restaurantTrans.addressTag, - restaurantTrans.time, - restaurant.homepage, - restaurant.instagram, - restaurantTrans.service + countMatchingWithKeyword(keywords), // 제목, 내용, 지역정보와 매칭되는 키워드 개수 + restaurant.createdAt )) .from(restaurant) .leftJoin(restaurant.firstImageFile, imageFile) .leftJoin(restaurant.restaurantTrans, restaurantTrans) .on(restaurantTrans.language.eq(language)) - .where(restaurantTrans.title.contains(keyword) - .or(restaurantTrans.addressTag.contains(keyword)) - .or(restaurantTrans.content.contains(keyword)) - .or(restaurant.id.in(idListContainHashtag)) - .or(restaurant.id.in(idListContainAllHashtags))) - .orderBy(restaurantTrans.createdAt.desc()) - .offset(pageable.getOffset()) - .limit(pageable.getPageSize()) .fetch(); - JPAQuery countQuery = queryFactory - .select(restaurant.countDistinct()) + // 해시태그 값을 matchedCount에 더해줌 + for (RestaurantSearchDto restaurantSearchDto : resultDto) { + Long id = restaurantSearchDto.getId(); + restaurantSearchDto.addMatchedCount(keywordMatchMap.getOrDefault(id, 0L)); + } + // matchedCount가 0이라면 검색결과에서 제거 + resultDto = resultDto.stream() + .filter(restaurantSearchDto -> restaurantSearchDto.getMatchedCount() > 0) + .toList(); + + // 매칭된 키워드 수 내림차순, 생성날짜 내림차순 정렬 + List resultList = new ArrayList<>(resultDto); + resultList.sort(Comparator + .comparing(RestaurantSearchDto::getMatchedCount, + Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(RestaurantSearchDto::getCreatedAt, + Comparator.nullsLast(Comparator.reverseOrder()))); + + // 페이징 처리 + int startIdx = pageable.getPageSize() * pageable.getPageNumber(); + int endIdx = Math.min(startIdx + pageable.getPageSize(), resultList.size()); + List finalList = resultList.subList(startIdx, endIdx); + final Long total = Long.valueOf(resultDto.size()); + + return PageableExecutionUtils.getPage(finalList, pageable, () -> total); + } + + /** + * 게시물의 제목, 주소태그, 키워드, 해시태그와 모두 겹치는 게시물이 있다면 조회 생성일자 내림차순 + * + * @param keywords 유저 키워드 리스트 + * @param language 유저 언어 + * @param pageable 페이징 + * @return 검색결과 + */ + @Override + public Page findSearchDtoByKeywordsIntersect(List keywords, + Language language, Pageable pageable) { + // restaurant_id를 가진 게시물의 해시태그가 검색어 키워드 중 몇개를 포함하는지 계산 + List keywordMatchQuery = queryFactory + .select(restaurant.id, restaurant.id.count()) + .from(restaurant) + .leftJoin(hashtag) + .on(hashtag.post.id.eq(restaurant.id) + .and(hashtag.language.eq(language))) + .innerJoin(hashtag.keyword, QKeyword.keyword) + .where(QKeyword.keyword.content.toLowerCase().trim().in(keywords)) + .groupBy(restaurant.id) + .fetch(); + + Map keywordMatchMap = keywordMatchQuery.stream() + .collect(Collectors.toMap( + tuple -> tuple.get(restaurant.id), // key: restaurant_id + tuple -> tuple.get(restaurant.id.count()) // value: 매칭된 키워드 개수 + )); + + List resultDto = queryFactory + .select(new QRestaurantSearchDto( + restaurant.id, + restaurantTrans.title, + imageFile.originUrl, + imageFile.thumbnailUrl, + countMatchingWithKeyword(keywords), // 제목, 내용, 지역정보와 매칭되는 키워드 개수 + restaurant.createdAt + )) .from(restaurant) .leftJoin(restaurant.firstImageFile, imageFile) .leftJoin(restaurant.restaurantTrans, restaurantTrans) .on(restaurantTrans.language.eq(language)) - .where(restaurantTrans.title.contains(keyword) - .or(restaurantTrans.addressTag.contains(keyword)) - .or(restaurantTrans.content.contains(keyword)) - .or(restaurant.id.in(idListContainAllHashtags))); + .fetch(); - return PageableExecutionUtils.getPage(resultDto, pageable, countQuery::fetchOne); + // 해시태그 값을 matchedCount에 더해줌 + for (RestaurantSearchDto restaurantSearchDto : resultDto) { + Long id = restaurantSearchDto.getId(); + restaurantSearchDto.addMatchedCount(keywordMatchMap.getOrDefault(id, 0L)); + } + // matchedCount가 키워드 개수와 다르다면 검색결과에서 제거 + resultDto = resultDto.stream() + .filter(restaurantSearchDto -> restaurantSearchDto.getMatchedCount() >= keywords.size()) + .toList(); + + // 생성날짜 내림차순 정렬 + List resultList = new ArrayList<>(resultDto); + resultList.sort(Comparator + .comparing(RestaurantSearchDto::getCreatedAt, + Comparator.nullsLast(Comparator.reverseOrder()))); + + // 페이징 처리 + int startIdx = pageable.getPageSize() * pageable.getPageNumber(); + int endIdx = Math.min(startIdx + pageable.getPageSize(), resultList.size()); + List finalList = resultList.subList(startIdx, endIdx); + final Long total = Long.valueOf(resultDto.size()); + + return PageableExecutionUtils.getPage(finalList, pageable, () -> total); } @Override @@ -395,15 +494,39 @@ public List findAllIds() { .fetch(); } - private List getIdListContainHashtag(String keyword, Language language) { - return queryFactory - .select(restaurant.id) - .from(restaurant) - .leftJoin(hashtag) - .on(hashtag.post.id.eq(restaurant.id) - .and(hashtag.category.eq(Category.RESTAURANT)) - .and(hashtag.language.eq(language))) - .where(hashtag.keyword.content.eq(keyword)) - .fetch(); + /** + * 제목, 주소태그, 내용과 일치하는 키워드 개수 카운팅 + * + * @param keywords 키워드 + * @return 키워드를 포함하는 조건 개수 + */ + private Expression countMatchingWithKeyword(List keywords) { + return Expressions.asNumber(0L) + .add(countMatchingConditionWithKeyword(restaurantTrans.title.toLowerCase().trim(), keywords, + 0)) + .add(countMatchingConditionWithKeyword(restaurantTrans.addressTag.toLowerCase().trim(), + keywords, 0)) + .add(countMatchingConditionWithKeyword(restaurantTrans.content, keywords, 0)); + } + + /** + * 조건이 키워드를 포함하는지 검사 + * + * @param condition 테이블 컬럼 + * @param keywords 유저 키워드 리스트 + * @param idx 키워드 인덱스 + * @return + */ + private Expression countMatchingConditionWithKeyword(StringExpression condition, + List keywords, int idx) { + if (idx == keywords.size()) { + return Expressions.asNumber(0); + } + + return new CaseBuilder() + .when(condition.contains(keywords.get(idx))) + .then(1) + .otherwise(0) + .add(countMatchingConditionWithKeyword(condition, keywords, idx + 1)); } } diff --git a/src/main/java/com/jeju/nanaland/domain/search/service/SearchService.java b/src/main/java/com/jeju/nanaland/domain/search/service/SearchService.java index 8350d427..826fcf7e 100644 --- a/src/main/java/com/jeju/nanaland/domain/search/service/SearchService.java +++ b/src/main/java/com/jeju/nanaland/domain/search/service/SearchService.java @@ -10,21 +10,21 @@ import com.jeju.nanaland.domain.common.data.Category; import com.jeju.nanaland.domain.common.data.Language; import com.jeju.nanaland.domain.common.dto.CompositeDto; -import com.jeju.nanaland.domain.experience.dto.ExperienceCompositeDto; +import com.jeju.nanaland.domain.experience.dto.ExperienceSearchDto; import com.jeju.nanaland.domain.experience.repository.ExperienceRepository; import com.jeju.nanaland.domain.favorite.service.MemberFavoriteService; -import com.jeju.nanaland.domain.festival.dto.FestivalCompositeDto; +import com.jeju.nanaland.domain.festival.dto.FestivalSearchDto; import com.jeju.nanaland.domain.festival.repository.FestivalRepository; -import com.jeju.nanaland.domain.market.dto.MarketCompositeDto; +import com.jeju.nanaland.domain.market.dto.MarketSearchDto; import com.jeju.nanaland.domain.market.repository.MarketRepository; import com.jeju.nanaland.domain.member.dto.MemberResponse.MemberInfoDto; import com.jeju.nanaland.domain.member.entity.Member; import com.jeju.nanaland.domain.nana.dto.NanaResponse.NanaThumbnailPost; -import com.jeju.nanaland.domain.nana.dto.NanaResponse.PreviewDto; +import com.jeju.nanaland.domain.nana.dto.NanaSearchDto; import com.jeju.nanaland.domain.nana.repository.NanaRepository; -import com.jeju.nanaland.domain.nature.dto.NatureCompositeDto; +import com.jeju.nanaland.domain.nature.dto.NatureSearchDto; import com.jeju.nanaland.domain.nature.repository.NatureRepository; -import com.jeju.nanaland.domain.restaurant.dto.RestaurantCompositeDto; +import com.jeju.nanaland.domain.restaurant.dto.RestaurantSearchDto; import com.jeju.nanaland.domain.restaurant.repository.RestaurantRepository; import com.jeju.nanaland.domain.search.dto.SearchResponse; import com.jeju.nanaland.domain.search.dto.SearchResponse.SearchVolumeDto; @@ -33,6 +33,7 @@ import com.jeju.nanaland.global.exception.NotFoundException; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Set; import java.util.concurrent.CompletableFuture; @@ -108,20 +109,41 @@ public SearchResponse.AllCategoryDto searchAll(MemberInfoDto memberInfoDto, Stri .build(); } - // 자연 검색 + /** + * 자연 검색 공백으로 구분된 키워드가 4개 이하라면 제목, 내용, 해시태그, 지역필터에 키워드가 하나라도 포함되면 조회 4개보다 많다면 모든 키워드가 모두 포함되어야 조회 + * + * @param memberInfoDto 유저 정보 + * @param keyword 유저 검색어 + * @param page 페이지 + * @param size 페이지 크기 + * @return 자연 검색 결과 + */ public SearchResponse.ResultDto searchNature(MemberInfoDto memberInfoDto, String keyword, int page, int size) { - Language locale = memberInfoDto.getLanguage(); + Language language = memberInfoDto.getLanguage(); Member member = memberInfoDto.getMember(); Pageable pageable = PageRequest.of(page, size); - Page resultPage = natureRepository.searchCompositeDtoByKeyword( - keyword, locale, pageable); + List normalizedKeywords = Arrays.stream(keyword.split("\\s+")) // 공백기준 분할 + .map(String::toLowerCase) // 소문자로 + .toList(); + + Page resultPage; + // 공백으로 구분한 키워드가 4개 이하라면 Union 검색 + if (normalizedKeywords.size() <= 4) { + resultPage = natureRepository.findSearchDtoByKeywordsUnion(normalizedKeywords, + language, pageable); + } + // 4개보다 많다면 Intersect 검색 + else { + resultPage = natureRepository.findSearchDtoByKeywordsIntersect(normalizedKeywords, + language, pageable); + } List favoriteIds = memberFavoriteService.getFavoritePostIdsWithMember(member); List thumbnails = new ArrayList<>(); - for (NatureCompositeDto dto : resultPage) { + for (NatureSearchDto dto : resultPage) { thumbnails.add( ThumbnailDto.builder() @@ -139,20 +161,41 @@ public SearchResponse.ResultDto searchNature(MemberInfoDto memberInfoDto, String .build(); } - // 축제 검색 + /** + * 축제 검색 공백으로 구분된 키워드가 4개 이하라면 제목, 내용, 해시태그, 지역필터에 키워드가 하나라도 포함되면 조회 4개보다 많다면 모든 키워드가 모두 포함되어야 조회 + * + * @param memberInfoDto 유저 정보 + * @param keyword 유저 검색어 + * @param page 페이지 + * @param size 페이지 크기 + * @return 축제 검색 결과 + */ public SearchResponse.ResultDto searchFestival(MemberInfoDto memberInfoDto, String keyword, int page, int size) { - Language locale = memberInfoDto.getLanguage(); + Language language = memberInfoDto.getLanguage(); Member member = memberInfoDto.getMember(); Pageable pageable = PageRequest.of(page, size); - Page resultPage = festivalRepository.searchCompositeDtoByKeyword( - keyword, locale, pageable); + List normalizedKeywords = Arrays.stream(keyword.split("\\s+")) // 공백기준 분할 + .map(String::toLowerCase) // 소문자로 + .toList(); + + Page resultPage; + // 공백으로 구분한 키워드가 4개 이하라면 Union 검색 + if (normalizedKeywords.size() <= 4) { + resultPage = festivalRepository.findSearchDtoByKeywordsUnion(normalizedKeywords, + language, pageable); + } + // 4개보다 많다면 Intersect 검색 + else { + resultPage = festivalRepository.findSearchDtoByKeywordsIntersect(normalizedKeywords, + language, pageable); + } List favoriteIds = memberFavoriteService.getFavoritePostIdsWithMember(member); List thumbnails = new ArrayList<>(); - for (FestivalCompositeDto dto : resultPage) { + for (FestivalSearchDto dto : resultPage) { thumbnails.add( ThumbnailDto.builder() @@ -170,20 +213,42 @@ public SearchResponse.ResultDto searchFestival(MemberInfoDto memberInfoDto, Stri .build(); } - // 이색체험 검색 + /** + * 이색체험 검색 공백으로 구분된 키워드가 4개 이하라면 제목, 내용, 해시태그, 지역필터에 키워드가 하나라도 포함되면 조회 4개보다 많다면 모든 키워드가 모두 포함되어야 + * 조회 + * + * @param memberInfoDto 유저 정보 + * @param keyword 유저 검색어 + * @param page 페이지 + * @param size 페이지 크기 + * @return 이색체험 검색 결과 + */ public SearchResponse.ResultDto searchExperience(MemberInfoDto memberInfoDto, String keyword, int page, int size) { - Language locale = memberInfoDto.getLanguage(); + Language language = memberInfoDto.getLanguage(); Member member = memberInfoDto.getMember(); Pageable pageable = PageRequest.of(page, size); - Page resultPage = experienceRepository.searchCompositeDtoByKeyword( - keyword, locale, pageable); + List normalizedKeywords = Arrays.stream(keyword.split("\\s+")) // 공백기준 분할 + .map(String::toLowerCase) // 소문자로 + .toList(); + + Page resultPage; + // 공백으로 구분한 키워드가 4개 이하라면 Union 검색 + if (normalizedKeywords.size() <= 4) { + resultPage = experienceRepository.findSearchDtoByKeywordsUnion(normalizedKeywords, + language, pageable); + } + // 4개보다 많다면 Intersect 검색 + else { + resultPage = experienceRepository.findSearchDtoByKeywordsIntersect(normalizedKeywords, + language, pageable); + } List favoriteIds = memberFavoriteService.getFavoritePostIdsWithMember(member); List thumbnails = new ArrayList<>(); - for (ExperienceCompositeDto dto : resultPage) { + for (ExperienceSearchDto dto : resultPage) { thumbnails.add( ThumbnailDto.builder() @@ -201,20 +266,42 @@ public SearchResponse.ResultDto searchExperience(MemberInfoDto memberInfoDto, St .build(); } - // 전통시장 검색 + /** + * 전통시장 검색 공백으로 구분된 키워드가 4개 이하라면 제목, 내용, 해시태그, 지역필터에 키워드가 하나라도 포함되면 조회 4개보다 많다면 모든 키워드가 모두 포함되어야 + * 조회 + * + * @param memberInfoDto 유저 정보 + * @param keyword 유저 검색어 + * @param page 페이지 + * @param size 페이지 크기 + * @return 전통시장 검색 결과 + */ public SearchResponse.ResultDto searchMarket(MemberInfoDto memberInfoDto, String keyword, int page, int size) { - - Language locale = memberInfoDto.getLanguage(); + Language language = memberInfoDto.getLanguage(); Member member = memberInfoDto.getMember(); Pageable pageable = PageRequest.of(page, size); - Page resultPage = marketRepository.searchCompositeDtoByKeyword( - keyword, locale, pageable); + List normalizedKeywords = Arrays.stream(keyword.split("\\s+")) // 공백기준 분할 + .map(String::toLowerCase) // 소문자로 + .toList(); + + Page resultPage; + // 공백으로 구분한 키워드가 4개 이하라면 Union 검색 + if (normalizedKeywords.size() <= 4) { + resultPage = marketRepository.findSearchDtoByKeywordsUnion(normalizedKeywords, + language, pageable); + } + // 4개보다 많다면 Intersect 검색 + else { + resultPage = marketRepository.findSearchDtoByKeywordsIntersect(normalizedKeywords, + language, pageable); + } List favoriteIds = memberFavoriteService.getFavoritePostIdsWithMember(member); List thumbnails = new ArrayList<>(); - for (MarketCompositeDto dto : resultPage) { + for (MarketSearchDto dto : resultPage) { + thumbnails.add( ThumbnailDto.builder() .id(dto.getId()) @@ -231,20 +318,41 @@ public SearchResponse.ResultDto searchMarket(MemberInfoDto memberInfoDto, String .build(); } - // 제주 맛집 검색 + /** + * 맛집 검색 공백으로 구분된 키워드가 4개 이하라면 제목, 내용, 해시태그, 지역필터에 키워드가 하나라도 포함되면 조회 4개보다 많다면 모든 키워드가 모두 포함되어야 조회 + * + * @param memberInfoDto 유저 정보 + * @param keyword 유저 검색어 + * @param page 페이지 + * @param size 페이지 크기 + * @return 맛집 검색 결과 + */ public SearchResponse.ResultDto searchRestaurant(MemberInfoDto memberInfoDto, String keyword, int page, int size) { - - Language locale = memberInfoDto.getLanguage(); + Language language = memberInfoDto.getLanguage(); Member member = memberInfoDto.getMember(); Pageable pageable = PageRequest.of(page, size); - Page resultPage = restaurantRepository.searchCompositeDtoByKeyword( - keyword, locale, pageable); + List normalizedKeywords = Arrays.stream(keyword.split("\\s+")) // 공백기준 분할 + .map(String::toLowerCase) // 소문자로 + .toList(); + + Page resultPage; + // 공백으로 구분한 키워드가 4개 이하라면 Union 검색 + if (normalizedKeywords.size() <= 4) { + resultPage = restaurantRepository.findSearchDtoByKeywordsUnion(normalizedKeywords, + language, pageable); + } + // 4개보다 많다면 Intersect 검색 + else { + resultPage = restaurantRepository.findSearchDtoByKeywordsIntersect(normalizedKeywords, + language, pageable); + } List favoriteIds = memberFavoriteService.getFavoritePostIdsWithMember(member); List thumbnails = new ArrayList<>(); - for (RestaurantCompositeDto dto : resultPage) { + for (RestaurantSearchDto dto : resultPage) { + thumbnails.add( ThumbnailDto.builder() .id(dto.getId()) @@ -261,27 +369,50 @@ public SearchResponse.ResultDto searchRestaurant(MemberInfoDto memberInfoDto, St .build(); } - // 나나스픽 검색 + /** + * 나나스픽 검색 공백으로 구분된 키워드가 4개 이하라면 제목, 내용, 해시태그, 지역필터에 키워드가 하나라도 포함되면 조회 4개보다 많다면 모든 키워드가 모두 포함되어야 + * 조회 + * + * @param memberInfoDto 유저 정보 + * @param keyword 유저 검색어 + * @param page 페이지 + * @param size 페이지 크기 + * @return 나나스픽 검색 결과 + */ public SearchResponse.ResultDto searchNana(MemberInfoDto memberInfoDto, String keyword, int page, int size) { - Language locale = memberInfoDto.getLanguage(); + Language language = memberInfoDto.getLanguage(); Member member = memberInfoDto.getMember(); Pageable pageable = PageRequest.of(page, size); - Page resultPage = nanaRepository.searchNanaThumbnailDtoByKeyword( - keyword, locale, pageable); + List normalizedKeywords = Arrays.stream(keyword.split("\\s+")) // 공백기준 분할 + .map(String::toLowerCase) // 소문자로 + .toList(); + + Page resultPage; + // 공백으로 구분한 키워드가 4개 이하라면 Union 검색 + if (normalizedKeywords.size() <= 4) { + resultPage = nanaRepository.findSearchDtoByKeywordsUnion(normalizedKeywords, + language, pageable); + } + // 4개보다 많다면 Intersect 검색 + else { + resultPage = nanaRepository.findSearchDtoByKeywordsIntersect(normalizedKeywords, + language, pageable); + } List favoriteIds = memberFavoriteService.getFavoritePostIdsWithMember(member); List thumbnails = new ArrayList<>(); - for (PreviewDto thumbnail : resultPage) { + for (NanaSearchDto dto : resultPage) { + thumbnails.add( ThumbnailDto.builder() - .id(thumbnail.getId()) + .id(dto.getId()) .category(NANA.name()) - .firstImage(thumbnail.getFirstImage()) - .title(thumbnail.getHeading()) - .isFavorite(favoriteIds.contains(thumbnail.getId())) + .firstImage(dto.getFirstImage()) + .title(dto.getTitle()) + .isFavorite(favoriteIds.contains(dto.getId())) .build()); } diff --git a/src/test/java/com/jeju/nanaland/domain/experience/repository/ExperienceRepositoryTest.java b/src/test/java/com/jeju/nanaland/domain/experience/repository/ExperienceRepositoryTest.java index 001dfcda..84a26e31 100644 --- a/src/test/java/com/jeju/nanaland/domain/experience/repository/ExperienceRepositoryTest.java +++ b/src/test/java/com/jeju/nanaland/domain/experience/repository/ExperienceRepositoryTest.java @@ -1,5 +1,7 @@ package com.jeju.nanaland.domain.experience.repository; +import static com.jeju.nanaland.domain.experience.entity.enums.ExperienceTypeKeyword.ART_MUSEUM; +import static com.jeju.nanaland.domain.experience.entity.enums.ExperienceTypeKeyword.EXHIBITION; import static com.jeju.nanaland.domain.experience.entity.enums.ExperienceTypeKeyword.HISTORY; import static com.jeju.nanaland.domain.experience.entity.enums.ExperienceTypeKeyword.LAND_LEISURE; import static com.jeju.nanaland.domain.experience.entity.enums.ExperienceTypeKeyword.MUSEUM; @@ -8,20 +10,27 @@ import com.jeju.nanaland.config.TestConfig; import com.jeju.nanaland.domain.common.data.AddressTag; +import com.jeju.nanaland.domain.common.data.Category; import com.jeju.nanaland.domain.common.data.Language; import com.jeju.nanaland.domain.common.entity.ImageFile; import com.jeju.nanaland.domain.experience.dto.ExperienceCompositeDto; import com.jeju.nanaland.domain.experience.dto.ExperienceResponse.ExperienceThumbnail; +import com.jeju.nanaland.domain.experience.dto.ExperienceSearchDto; import com.jeju.nanaland.domain.experience.entity.Experience; import com.jeju.nanaland.domain.experience.entity.ExperienceKeyword; import com.jeju.nanaland.domain.experience.entity.ExperienceTrans; import com.jeju.nanaland.domain.experience.entity.enums.ExperienceType; import com.jeju.nanaland.domain.experience.entity.enums.ExperienceTypeKeyword; +import com.jeju.nanaland.domain.hashtag.entity.Hashtag; +import com.jeju.nanaland.domain.hashtag.entity.Keyword; +import jakarta.persistence.TypedQuery; import java.util.ArrayList; import java.util.List; import java.util.Set; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; @@ -126,6 +135,49 @@ void getExperienceTypeKeywordSetTest() { ); } + @ParameterizedTest + @EnumSource(value = Language.class) + void findSearchDtoByKeywordsUnionTest(Language language) { + // given + Pageable pageable = PageRequest.of(0, 10); + List experiences1 = + getActivityList(language, List.of(LAND_LEISURE, WATER_LEISURE), "제주시", 2); + initHashtags(experiences1, List.of("keyword1", "kEyWoRd2"), language); + List experiences2 = + getCultureAndArtsList(language, List.of(EXHIBITION, MUSEUM, ART_MUSEUM), "서귀포시", 3); + initHashtags(experiences2, List.of("keyword2", "kEyWoRd3"), language); + + // when + Page resultDto = experienceRepository.findSearchDtoByKeywordsUnion( + List.of("keyword2", "keyword3"), language, pageable); + + // then + assertThat(resultDto.getTotalElements()).isEqualTo(5); + assertThat(resultDto.getContent().get(0).getMatchedCount()).isEqualTo(2); + assertThat(resultDto.getContent().get(3).getMatchedCount()).isEqualTo(1); + } + + @ParameterizedTest + @EnumSource(value = Language.class) + void findSearchDtoByKeywordsIntersectTest(Language language) { + // given + Pageable pageable = PageRequest.of(0, 10); + List keywords = List.of("keyword1", "keyword2", "keyword3", "keyword4", "keyword5"); + List experiences1 = + getActivityList(language, List.of(LAND_LEISURE, WATER_LEISURE), "제주시", 2); + initHashtags(experiences1, keywords, language); + List experiences2 = + getCultureAndArtsList(language, List.of(EXHIBITION, MUSEUM, ART_MUSEUM), "서귀포시", 3); + initHashtags(experiences2, List.of("keyword1", "kEyWoRd2"), language); + + // when + Page resultDto = experienceRepository.findSearchDtoByKeywordsIntersect( + keywords, language, pageable); + + // then + assertThat(resultDto.getTotalElements()).isEqualTo(2); + } + private List getActivityList(Language language, List keywordList, String addressTag, int size) { List experienceList = new ArrayList<>(); @@ -199,4 +251,37 @@ private List getCultureAndArtsList(Language language, return cultureAndArtsList; } + + private void initHashtags(List experiences, List keywords, + Language language) { + List keywordList = new ArrayList<>(); + for (String k : keywords) { + TypedQuery query = em.getEntityManager().createQuery( + "SELECT k FROM Keyword k WHERE k.content = :keyword", Keyword.class); + query.setParameter("keyword", k); + List resultList = query.getResultList(); + + if (resultList.isEmpty()) { + Keyword newKeyword = Keyword.builder() + .content(k) + .build(); + em.persist(newKeyword); + keywordList.add(newKeyword); + } else { + keywordList.add(resultList.get(0)); + } + } + + for (Experience experience : experiences) { + for (Keyword k : keywordList) { + Hashtag newHashtag = Hashtag.builder() + .post(experience) + .category(Category.EXPERIENCE) + .language(language) + .keyword(k) + .build(); + em.persist(newHashtag); + } + } + } } \ No newline at end of file diff --git a/src/test/java/com/jeju/nanaland/domain/festival/repository/FestivalRepositoryTest.java b/src/test/java/com/jeju/nanaland/domain/festival/repository/FestivalRepositoryTest.java index 16f7ff6a..f2823156 100644 --- a/src/test/java/com/jeju/nanaland/domain/festival/repository/FestivalRepositoryTest.java +++ b/src/test/java/com/jeju/nanaland/domain/festival/repository/FestivalRepositoryTest.java @@ -1,20 +1,28 @@ package com.jeju.nanaland.domain.festival.repository; +import static org.assertj.core.api.Assertions.assertThat; + import com.jeju.nanaland.config.TestConfig; import com.jeju.nanaland.domain.common.data.AddressTag; +import com.jeju.nanaland.domain.common.data.Category; import com.jeju.nanaland.domain.common.data.Language; import com.jeju.nanaland.domain.common.entity.ImageFile; import com.jeju.nanaland.domain.festival.dto.FestivalCompositeDto; +import com.jeju.nanaland.domain.festival.dto.FestivalSearchDto; import com.jeju.nanaland.domain.festival.entity.Festival; import com.jeju.nanaland.domain.festival.entity.FestivalTrans; +import com.jeju.nanaland.domain.hashtag.entity.Hashtag; +import com.jeju.nanaland.domain.hashtag.entity.Keyword; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; +import jakarta.persistence.TypedQuery; import java.time.LocalDate; import java.util.ArrayList; import java.util.List; -import org.assertj.core.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.context.annotation.Import; @@ -36,11 +44,51 @@ class FestivalRepositoryTest { @PersistenceContext private EntityManager em; - @Test - @DisplayName("축제 검색") - void searchFestivalTest() { + @ParameterizedTest + @EnumSource(value = Language.class) + @DisplayName("키워드 4개 이하 검색") + void findSearchDtoByKeywordsUnionTestTest(Language language) { + // given + Pageable pageable = PageRequest.of(0, 12); + int size = 3; + for (int i = 0; i < size; i++) { + Festival festival = createFestival((long) i); + FestivalTrans festivalTrans = createFestivalTrans(festival, i, "test", "제주시"); + initHashtags(List.of(festival), List.of("keyword" + i, "keyword" + (i + 1)), language); + } + + // when + Page resultDto = festivalRepository.findSearchDtoByKeywordsUnion( + List.of("keyword1", "keyword2"), language, pageable); + + // then + assertThat(resultDto.getTotalElements()).isEqualTo(3); + assertThat(resultDto.getContent().get(0).getMatchedCount()).isEqualTo(2); + } + + @ParameterizedTest + @EnumSource(value = Language.class) + @DisplayName("키워드 5개 이상 검색") + void findSearchDtoByKeywordsIntersectTest(Language language) { + // given Pageable pageable = PageRequest.of(0, 12); - festivalRepository.searchCompositeDtoByKeyword("쇼핑", Language.KOREAN, pageable); + int size = 3; + for (int i = 0; i < size; i++) { + Festival festival = createFestival((long) i); + FestivalTrans festivalTrans = createFestivalTrans(festival, i, "test", "제주시"); + initHashtags(List.of(festival), + List.of("keyword" + i, "keyword" + (i + 1), "keyword" + (i + 2), "keyword" + (i + 3), + "keyword" + (i + 4)), + language); + } + + // when + Page resultDto = festivalRepository.findSearchDtoByKeywordsIntersect( + List.of("keyword1", "keyword2", "keyword3", "keyword4", "keyword5"), language, pageable); + + // then + assertThat(resultDto.getTotalElements()).isEqualTo(1); + assertThat(resultDto.getContent().get(0).getMatchedCount()).isEqualTo(5); } @Test @@ -56,23 +104,25 @@ void searchCompositeDtoByOnGoing() { List onGoingFestivalWithoutAddressFilter = festivalCompositeDtoPage.getContent(); List onGoingFestivalWithAddressFilter = festivalRepository.searchCompositeDtoByOnGoing( - Language.KOREAN, PageRequest.of(0, 5), true, new ArrayList<>(List.of(AddressTag.JEJU))).getContent(); + Language.KOREAN, PageRequest.of(0, 5), true, new ArrayList<>(List.of(AddressTag.JEJU))) + .getContent(); List finishFestivalWithoutAddressFilter = festivalRepository.searchCompositeDtoByOnGoing( Language.KOREAN, PageRequest.of(0, 5), false, new ArrayList<>()).getContent(); List finishFestivalWithAddressFilter = festivalRepository.searchCompositeDtoByOnGoing( - Language.KOREAN, PageRequest.of(0, 5), false, new ArrayList<>(List.of(AddressTag.HALLIM))).getContent(); + Language.KOREAN, PageRequest.of(0, 5), false, new ArrayList<>(List.of(AddressTag.HALLIM))) + .getContent(); // Then - Assertions.assertThat(festivalCompositeDtoPage.getTotalElements()).isEqualTo(3); - Assertions.assertThat(festivalCompositeDtoPage.getTotalPages()).isEqualTo(1); + assertThat(festivalCompositeDtoPage.getTotalElements()).isEqualTo(3); + assertThat(festivalCompositeDtoPage.getTotalPages()).isEqualTo(1); - Assertions.assertThat(onGoingFestivalWithoutAddressFilter.size()).isEqualTo(3); - Assertions.assertThat(onGoingFestivalWithAddressFilter.size()).isEqualTo(2); + assertThat(onGoingFestivalWithoutAddressFilter.size()).isEqualTo(3); + assertThat(onGoingFestivalWithAddressFilter.size()).isEqualTo(2); - Assertions.assertThat(finishFestivalWithoutAddressFilter.size()).isEqualTo(2); - Assertions.assertThat(finishFestivalWithAddressFilter.size()).isEqualTo(1); + assertThat(finishFestivalWithoutAddressFilter.size()).isEqualTo(2); + assertThat(finishFestivalWithAddressFilter.size()).isEqualTo(1); } @Test @@ -93,13 +143,13 @@ void searchCompositeDtoBySeason() { Language.KOREAN, PageRequest.of(0, 5), "겨울").getContent(); // Then - Assertions.assertThat(springFestivalPage.getTotalElements()).isEqualTo(3); - Assertions.assertThat(springFestivalPage.getTotalPages()).isEqualTo(1); + assertThat(springFestivalPage.getTotalElements()).isEqualTo(3); + assertThat(springFestivalPage.getTotalPages()).isEqualTo(1); - Assertions.assertThat(springFestival.size()).isEqualTo(3); - Assertions.assertThat(summerFestival.size()).isEqualTo(2); - Assertions.assertThat(autumnFestival.size()).isEqualTo(2); - Assertions.assertThat(springFestival.size()).isEqualTo(3); + assertThat(springFestival.size()).isEqualTo(3); + assertThat(summerFestival.size()).isEqualTo(2); + assertThat(autumnFestival.size()).isEqualTo(2); + assertThat(springFestival.size()).isEqualTo(3); } @Test @@ -118,11 +168,11 @@ void searchCompositeDtoByMonth() { LocalDate.of(2024, 1, 1), LocalDate.of(2024, 12, 13), new ArrayList<>()).getContent(); // Then - Assertions.assertThat(allFestivalPage.getTotalElements()).isEqualTo(5); - Assertions.assertThat(allFestivalPage.getTotalPages()).isEqualTo(1); + assertThat(allFestivalPage.getTotalElements()).isEqualTo(5); + assertThat(allFestivalPage.getTotalPages()).isEqualTo(1); - Assertions.assertThat(allFestival.size()).isEqualTo(5); - Assertions.assertThat(festivalByDate.size()).isEqualTo(3); + assertThat(allFestival.size()).isEqualTo(5); + assertThat(festivalByDate.size()).isEqualTo(3); } @@ -255,4 +305,68 @@ private void setFestival() { em.persist(festivalTrans5); } + + private ImageFile createImageFile(Long number) { + ImageFile imageFile = ImageFile.builder() + .originUrl("origin" + number) + .thumbnailUrl("thumbnail" + number) + .build(); + em.persist(imageFile); + return imageFile; + } + + private Festival createFestival(Long priority) { + Festival festival = Festival.builder() + .firstImageFile(createImageFile(priority)) + .priority(priority) + .build(); + em.persist(festival); + return festival; + } + + private FestivalTrans createFestivalTrans(Festival festival, int number, String keyword, + String addressTag) { + FestivalTrans festivalTrans = FestivalTrans.builder() + .festival(festival) + .language(Language.KOREAN) + .title(keyword + "title" + number) + .content("content" + number) + .addressTag(addressTag) + .build(); + em.persist(festivalTrans); + return festivalTrans; + } + + private void initHashtags(List festivals, List keywords, + Language language) { + List keywordList = new ArrayList<>(); + for (String k : keywords) { + TypedQuery query = em.createQuery( + "SELECT k FROM Keyword k WHERE k.content = :keyword", Keyword.class); + query.setParameter("keyword", k); + List resultList = query.getResultList(); + + if (resultList.isEmpty()) { + Keyword newKeyword = Keyword.builder() + .content(k) + .build(); + em.persist(newKeyword); + keywordList.add(newKeyword); + } else { + keywordList.add(resultList.get(0)); + } + } + + for (Festival festival : festivals) { + for (Keyword k : keywordList) { + Hashtag newHashtag = Hashtag.builder() + .post(festival) + .category(Category.FESTIVAL) + .language(language) + .keyword(k) + .build(); + em.persist(newHashtag); + } + } + } } \ No newline at end of file diff --git a/src/test/java/com/jeju/nanaland/domain/market/repository/MarketRepositoryTest.java b/src/test/java/com/jeju/nanaland/domain/market/repository/MarketRepositoryTest.java index f7057543..05334111 100644 --- a/src/test/java/com/jeju/nanaland/domain/market/repository/MarketRepositoryTest.java +++ b/src/test/java/com/jeju/nanaland/domain/market/repository/MarketRepositoryTest.java @@ -4,16 +4,23 @@ import com.jeju.nanaland.config.TestConfig; import com.jeju.nanaland.domain.common.data.AddressTag; +import com.jeju.nanaland.domain.common.data.Category; import com.jeju.nanaland.domain.common.data.Language; import com.jeju.nanaland.domain.common.entity.ImageFile; +import com.jeju.nanaland.domain.hashtag.entity.Hashtag; +import com.jeju.nanaland.domain.hashtag.entity.Keyword; import com.jeju.nanaland.domain.market.dto.MarketResponse.MarketThumbnail; +import com.jeju.nanaland.domain.market.dto.MarketSearchDto; import com.jeju.nanaland.domain.market.entity.Market; import com.jeju.nanaland.domain.market.entity.MarketTrans; +import jakarta.persistence.TypedQuery; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; @@ -33,11 +40,51 @@ class MarketRepositoryTest { @Autowired TestEntityManager em; - @Test - @DisplayName("전통시장 검색") - void searchMarketTest() { + @ParameterizedTest + @EnumSource(value = Language.class) + @DisplayName("키워드 4개 이하 검색") + void findSearchDtoByKeywordsUnionTest(Language language) { + // given + Pageable pageable = PageRequest.of(0, 12); + int size = 3; + for (int i = 0; i < size; i++) { + Market market = createMarket((long) i); + MarketTrans marketTrans = createMarketTrans(market, i, "test", "제주시"); + initHashtags(List.of(market), List.of("keyword" + i, "keyword" + (i + 1)), language); + } + + // when + Page resultDto = marketRepository.findSearchDtoByKeywordsUnion( + List.of("keyword1", "keyword2"), language, pageable); + + // then + assertThat(resultDto.getTotalElements()).isEqualTo(3); + assertThat(resultDto.getContent().get(0).getMatchedCount()).isEqualTo(2); + } + + @ParameterizedTest + @EnumSource(value = Language.class) + @DisplayName("키워드 5개 이상 검색") + void findSearchDtoByKeywordsIntersectTest(Language language) { + // given Pageable pageable = PageRequest.of(0, 12); - marketRepository.searchCompositeDtoByKeyword("쇼핑", Language.KOREAN, pageable); + int size = 3; + for (int i = 0; i < size; i++) { + Market market = createMarket((long) i); + MarketTrans marketTrans = createMarketTrans(market, i, "test", "제주시"); + initHashtags(List.of(market), + List.of("keyword" + i, "keyword" + (i + 1), "keyword" + (i + 2), "keyword" + (i + 3), + "keyword" + (i + 4)), + language); + } + + // when + Page resultDto = marketRepository.findSearchDtoByKeywordsIntersect( + List.of("keyword1", "keyword2", "keyword3", "keyword4", "keyword5"), language, pageable); + + // then + assertThat(resultDto.getTotalElements()).isEqualTo(1); + assertThat(resultDto.getContent().get(0).getMatchedCount()).isEqualTo(5); } @Test @@ -98,4 +145,68 @@ List getMarketList(Language language) { return marketList; } + + private ImageFile createImageFile(Long number) { + ImageFile imageFile = ImageFile.builder() + .originUrl("origin" + number) + .thumbnailUrl("thumbnail" + number) + .build(); + em.persist(imageFile); + return imageFile; + } + + private Market createMarket(Long priority) { + Market market = Market.builder() + .firstImageFile(createImageFile(priority)) + .priority(priority) + .build(); + em.persist(market); + return market; + } + + private MarketTrans createMarketTrans(Market market, int number, String keyword, + String addressTag) { + MarketTrans marketTrans = MarketTrans.builder() + .market(market) + .language(Language.KOREAN) + .title(keyword + "title" + number) + .content("content" + number) + .addressTag(addressTag) + .build(); + em.persist(marketTrans); + return marketTrans; + } + + private void initHashtags(List markets, List keywords, + Language language) { + List keywordList = new ArrayList<>(); + for (String k : keywords) { + TypedQuery query = em.getEntityManager().createQuery( + "SELECT k FROM Keyword k WHERE k.content = :keyword", Keyword.class); + query.setParameter("keyword", k); + List resultList = query.getResultList(); + + if (resultList.isEmpty()) { + Keyword newKeyword = Keyword.builder() + .content(k) + .build(); + em.persist(newKeyword); + keywordList.add(newKeyword); + } else { + keywordList.add(resultList.get(0)); + } + } + + for (Market market : markets) { + for (Keyword k : keywordList) { + Hashtag newHashtag = Hashtag.builder() + .post(market) + .category(Category.MARKET) + .language(language) + .keyword(k) + .build(); + em.persist(newHashtag); + } + } + } } \ No newline at end of file diff --git a/src/test/java/com/jeju/nanaland/domain/nature/repository/NatureRepositoryTest.java b/src/test/java/com/jeju/nanaland/domain/nature/repository/NatureRepositoryTest.java index d63ca12d..4b6ea655 100644 --- a/src/test/java/com/jeju/nanaland/domain/nature/repository/NatureRepositoryTest.java +++ b/src/test/java/com/jeju/nanaland/domain/nature/repository/NatureRepositoryTest.java @@ -4,17 +4,24 @@ import com.jeju.nanaland.config.TestConfig; import com.jeju.nanaland.domain.common.data.AddressTag; +import com.jeju.nanaland.domain.common.data.Category; import com.jeju.nanaland.domain.common.data.Language; import com.jeju.nanaland.domain.common.entity.ImageFile; +import com.jeju.nanaland.domain.hashtag.entity.Hashtag; +import com.jeju.nanaland.domain.hashtag.entity.Keyword; import com.jeju.nanaland.domain.nature.dto.NatureCompositeDto; import com.jeju.nanaland.domain.nature.dto.NatureResponse; +import com.jeju.nanaland.domain.nature.dto.NatureSearchDto; import com.jeju.nanaland.domain.nature.entity.Nature; import com.jeju.nanaland.domain.nature.entity.NatureTrans; +import jakarta.persistence.TypedQuery; import java.util.ArrayList; import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; @@ -52,7 +59,8 @@ private Nature createNature(Long priority) { return nature; } - private NatureTrans createNatureTrans(Nature nature, int number, String keyword, String addressTag) { + private NatureTrans createNatureTrans(Nature nature, int number, String keyword, + String addressTag) { NatureTrans natureTrans = NatureTrans.builder() .nature(nature) .language(Language.KOREAN) @@ -71,11 +79,84 @@ private void createNatureItems(int itemCount, String keyword, String addressTag) } } - @Test - @DisplayName("7대 자연 검색 TEST") - void searchNatureTest() { + private void initHashtags(List natures, List keywords, + Language language) { + List keywordList = new ArrayList<>(); + for (String k : keywords) { + TypedQuery query = entityManager.getEntityManager().createQuery( + "SELECT k FROM Keyword k WHERE k.content = :keyword", Keyword.class); + query.setParameter("keyword", k); + List resultList = query.getResultList(); + + if (resultList.isEmpty()) { + Keyword newKeyword = Keyword.builder() + .content(k) + .build(); + entityManager.persist(newKeyword); + keywordList.add(newKeyword); + } else { + keywordList.add(resultList.get(0)); + } + } + + for (Nature nature : natures) { + for (Keyword k : keywordList) { + Hashtag newHashtag = Hashtag.builder() + .post(nature) + .category(Category.NATURE) + .language(language) + .keyword(k) + .build(); + entityManager.persist(newHashtag); + } + } + } + + @ParameterizedTest + @EnumSource(value = Language.class) + @DisplayName("키워드 4개 이하 검색") + void findSearchDtoByKeywordsUnionTest(Language language) { + // given Pageable pageable = PageRequest.of(0, 12); - natureRepository.searchCompositeDtoByKeyword("자연경관", Language.KOREAN, pageable); + int size = 3; + for (int i = 0; i < size; i++) { + Nature nature = createNature((long) i); + NatureTrans natureTrans = createNatureTrans(nature, i, "test", "제주시"); + initHashtags(List.of(nature), List.of("keyword" + i, "keyword" + (i + 1)), language); + } + + // when + Page resultDto = natureRepository.findSearchDtoByKeywordsUnion( + List.of("keyword1", "keyword2"), language, pageable); + + // then + assertThat(resultDto.getTotalElements()).isEqualTo(3); + assertThat(resultDto.getContent().get(0).getMatchedCount()).isEqualTo(2); + } + + @ParameterizedTest + @EnumSource(value = Language.class) + @DisplayName("키워드 5개 이상 검색") + void findSearchDtoByKeywordsIntersectTest(Language language) { + // given + Pageable pageable = PageRequest.of(0, 12); + int size = 3; + for (int i = 0; i < size; i++) { + Nature nature = createNature((long) i); + NatureTrans natureTrans = createNatureTrans(nature, i, "test", "제주시"); + initHashtags(List.of(nature), + List.of("keyword" + i, "keyword" + (i + 1), "keyword" + (i + 2), "keyword" + (i + 3), + "keyword" + (i + 4)), + language); + } + + // when + Page resultDto = natureRepository.findSearchDtoByKeywordsIntersect( + List.of("keyword1", "keyword2", "keyword3", "keyword4", "keyword5"), language, pageable); + + // then + assertThat(resultDto.getTotalElements()).isEqualTo(1); + assertThat(resultDto.getContent().get(0).getMatchedCount()).isEqualTo(5); } @Test @@ -99,6 +180,7 @@ void findNatureCompositeDto() { @Nested @DisplayName("7대 자연 프리뷰 페이징 조회 TEST") class findAllNaturePreviewDtoOrderByCreatedAt { + @Test @DisplayName("기본 케이스") void findAllNaturePreviewDtoOrderByPriority_basic() { diff --git a/src/test/java/com/jeju/nanaland/domain/restaurant/repository/RestaurantRepositoryTest.java b/src/test/java/com/jeju/nanaland/domain/restaurant/repository/RestaurantRepositoryTest.java new file mode 100644 index 00000000..693999fe --- /dev/null +++ b/src/test/java/com/jeju/nanaland/domain/restaurant/repository/RestaurantRepositoryTest.java @@ -0,0 +1,150 @@ +package com.jeju.nanaland.domain.restaurant.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.jeju.nanaland.config.TestConfig; +import com.jeju.nanaland.domain.common.data.Category; +import com.jeju.nanaland.domain.common.data.Language; +import com.jeju.nanaland.domain.common.entity.ImageFile; +import com.jeju.nanaland.domain.hashtag.entity.Hashtag; +import com.jeju.nanaland.domain.hashtag.entity.Keyword; +import com.jeju.nanaland.domain.restaurant.dto.RestaurantSearchDto; +import com.jeju.nanaland.domain.restaurant.entity.Restaurant; +import com.jeju.nanaland.domain.restaurant.entity.RestaurantTrans; +import jakarta.persistence.TypedQuery; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +@DataJpaTest +@Import(TestConfig.class) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +public class RestaurantRepositoryTest { + + @Autowired + RestaurantRepository restaurantRepository; + + @Autowired + TestEntityManager em; + + @ParameterizedTest + @EnumSource(value = Language.class) + @DisplayName("키워드 4개 이하 검색") + void findSearchDtoByKeywordsUnionTest(Language language) { + // given + Pageable pageable = PageRequest.of(0, 12); + int size = 3; + for (int i = 0; i < size; i++) { + Restaurant restaurant = createRestaurant((long) i); + RestaurantTrans restaurantTrans = createRestaurantTrans(restaurant, i, "test", "제주시"); + initHashtags(List.of(restaurant), List.of("keyword" + i, "keyword" + (i + 1)), language); + } + + // when + Page resultDto = restaurantRepository.findSearchDtoByKeywordsUnion( + List.of("keyword1", "keyword2"), language, pageable); + + // then + assertThat(resultDto.getTotalElements()).isEqualTo(3); + assertThat(resultDto.getContent().get(0).getMatchedCount()).isEqualTo(2); + } + + @ParameterizedTest + @EnumSource(value = Language.class) + @DisplayName("키워드 5개 이상 검색") + void findSearchDtoByKeywordsIntersectTest(Language language) { + // given + Pageable pageable = PageRequest.of(0, 12); + int size = 3; + for (int i = 0; i < size; i++) { + Restaurant restaurant = createRestaurant((long) i); + RestaurantTrans restaurantTrans = createRestaurantTrans(restaurant, i, "test", "제주시"); + initHashtags(List.of(restaurant), + List.of("keyword" + i, "keyword" + (i + 1), "keyword" + (i + 2), "keyword" + (i + 3), + "keyword" + (i + 4)), + language); + } + + // when + Page resultDto = restaurantRepository.findSearchDtoByKeywordsIntersect( + List.of("keyword1", "keyword2", "keyword3", "keyword4", "keyword5"), language, pageable); + + // then + assertThat(resultDto.getTotalElements()).isEqualTo(1); + assertThat(resultDto.getContent().get(0).getMatchedCount()).isEqualTo(5); + } + + private ImageFile createImageFile(Long number) { + ImageFile imageFile = ImageFile.builder() + .originUrl("origin" + number) + .thumbnailUrl("thumbnail" + number) + .build(); + em.persist(imageFile); + return imageFile; + } + + private Restaurant createRestaurant(Long priority) { + Restaurant restaurant = Restaurant.builder() + .firstImageFile(createImageFile(priority)) + .priority(priority) + .build(); + em.persist(restaurant); + return restaurant; + } + + private RestaurantTrans createRestaurantTrans(Restaurant restaurant, int number, String keyword, + String addressTag) { + RestaurantTrans restaurantTrans = RestaurantTrans.builder() + .restaurant(restaurant) + .language(Language.KOREAN) + .title(keyword + "title" + number) + .content("content" + number) + .addressTag(addressTag) + .build(); + em.persist(restaurantTrans); + return restaurantTrans; + } + + private void initHashtags(List restaurants, List keywords, + Language language) { + List keywordList = new ArrayList<>(); + for (String k : keywords) { + TypedQuery query = em.getEntityManager().createQuery( + "SELECT k FROM Keyword k WHERE k.content = :keyword", Keyword.class); + query.setParameter("keyword", k); + List resultList = query.getResultList(); + + if (resultList.isEmpty()) { + Keyword newKeyword = Keyword.builder() + .content(k) + .build(); + em.persist(newKeyword); + keywordList.add(newKeyword); + } else { + keywordList.add(resultList.get(0)); + } + } + + for (Restaurant restaurant : restaurants) { + for (Keyword k : keywordList) { + Hashtag newHashtag = Hashtag.builder() + .post(restaurant) + .category(Category.RESTAURANT) + .language(language) + .keyword(k) + .build(); + em.persist(newHashtag); + } + } + } +} diff --git a/src/test/java/com/jeju/nanaland/domain/search/service/SearchServiceTest.java b/src/test/java/com/jeju/nanaland/domain/search/service/SearchServiceTest.java index 6047f817..14c9a248 100644 --- a/src/test/java/com/jeju/nanaland/domain/search/service/SearchServiceTest.java +++ b/src/test/java/com/jeju/nanaland/domain/search/service/SearchServiceTest.java @@ -1,23 +1,16 @@ package com.jeju.nanaland.domain.search.service; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; -import com.jeju.nanaland.domain.common.data.Language; import com.jeju.nanaland.domain.experience.repository.ExperienceRepository; import com.jeju.nanaland.domain.favorite.service.MemberFavoriteService; import com.jeju.nanaland.domain.festival.repository.FestivalRepository; import com.jeju.nanaland.domain.market.repository.MarketRepository; -import com.jeju.nanaland.domain.member.dto.MemberResponse.MemberInfoDto; -import com.jeju.nanaland.domain.member.entity.Member; import com.jeju.nanaland.domain.nana.repository.NanaRepository; import com.jeju.nanaland.domain.nature.repository.NatureRepository; import com.jeju.nanaland.domain.restaurant.repository.RestaurantRepository; import com.jeju.nanaland.global.config.RedisConfig; -import java.util.List; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.parallel.Execution; import org.junit.jupiter.api.parallel.ExecutionMode; @@ -25,8 +18,6 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.annotation.Import; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ZSetOperations; @@ -63,45 +54,4 @@ public void setup() { when(redisTemplate.opsForZSet()).thenReturn(zSetOperations); } - - @Test - @DisplayName("전체 카테고리 검색") - void searchAllTest() { - // given - Member member = Member.builder() - .language(Language.KOREAN) - .build(); - MemberInfoDto memberInfoDto = MemberInfoDto.builder() - .member(member) - .language(Language.KOREAN) - .build(); - - when(zSetOperations.incrementScore(any(String.class), any(String.class), any(Double.class))) - .thenReturn(1.0); - when(natureRepository.searchCompositeDtoByKeyword(any(String.class), any(Language.class), any( - Pageable.class))) - .thenReturn(Page.empty()); - when(festivalRepository.searchCompositeDtoByKeyword(any(String.class), any(Language.class), any( - Pageable.class))) - .thenReturn(Page.empty()); - when(marketRepository.searchCompositeDtoByKeyword(any(String.class), any(Language.class), - any(Pageable.class))) - .thenReturn(Page.empty()); - when(experienceRepository.searchCompositeDtoByKeyword(any(String.class), any(Language.class), - any(Pageable.class))) - .thenReturn(Page.empty()); - when(restaurantRepository.searchCompositeDtoByKeyword(any(String.class), any(Language.class), - any(Pageable.class))) - .thenReturn(Page.empty()); - when(nanaRepository.searchNanaThumbnailDtoByKeyword(any(String.class), any(Language.class), - any(Pageable.class))) - .thenReturn(Page.empty()); - when(memberFavoriteService.getFavoritePostIdsWithMember(any(Member.class))) - .thenReturn(List.of()); - - // when - searchService.searchAll(memberInfoDto, "TEST"); - - // then - } } \ No newline at end of file