Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,11 @@
import org.springframework.util.StringUtils;

import static com.example.umc9th.domain.review.entity.QReview.review;
import static com.example.umc9th.domain.store.entity.QStore.store;

public class ReviewPredicate {

private ReviewPredicate() {}

public static BooleanExpression storeNameContains(String storeName) {
return StringUtils.hasText(storeName) ? store.name.contains(storeName) : null;
}

public static BooleanExpression starRange(Float minStar, Float maxStar) {
if (minStar != null && maxStar != null) {
return review.star.between(minStar, maxStar);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
import java.util.List;

import static com.example.umc9th.domain.review.entity.QReview.review;
import static com.example.umc9th.domain.store.entity.QStore.store;
import static com.example.umc9th.domain.user.entity.QUser.user;
import static com.example.umc9th.domain.review.entity.QReviewImage.reviewImage;
import static com.example.umc9th.domain.review.entity.QReviewReply.reviewReply;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
import com.example.umc9th.domain.review.dto.ReviewResponseDto;
import com.example.umc9th.domain.review.entity.Review;
import com.example.umc9th.domain.review.repository.ReviewPredicate;
import com.example.umc9th.domain.review.repository.ReviewQueryDsl;
import com.example.umc9th.domain.review.repository.ReviewRepository;
import com.example.umc9th.domain.store.repository.StorePredicate;
import com.example.umc9th.domain.store.repository.StoreRepository;
import com.example.umc9th.domain.user.repository.UserRepository;
import com.example.umc9th.global.dto.CursorResponseDto;
Expand Down Expand Up @@ -49,7 +49,7 @@ public CursorResponseDto<ReviewResponseDto.Review> getReviews(String storeName,
// 1. 서비스에서 Predicate 조합
BooleanBuilder predicate = new BooleanBuilder();
predicate.and(ReviewPredicate.userIdEquals(1L));// todo: 로그인한 사용자
predicate.and(ReviewPredicate.storeNameContains(storeName));
predicate.and(StorePredicate.storeNameContains(storeName));
predicate.and(ReviewPredicate.starRange(minStar, maxStar));

// 2. Pageable 객체 생성
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.example.umc9th.domain.store.controller;

import com.example.umc9th.domain.store.dto.StoreResponseDto;
import com.example.umc9th.domain.store.service.StoreService;
import com.example.umc9th.global.dto.CursorResponseDto;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.rmi.registry.Registry;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/stores")
@Tag(name = "가게")
public class StoreController {
private final StoreService storeService;

@Operation(
summary = "가게 검색",
description = """
1-1. 지역 필터링: region 기반, 다중 선택 가능
1-2. 이름 검색:
- 공백 포함: 각 단어 포함 가게 합집합 조회
- 공백 없음: 전체 검색어 포함 가게 조회
1-3. 정렬 조건:
- latest: 최신순
- name: 가나다 → 영어 대문자 → 영어 소문자 → 특수문자, 이름 동일 시 최신순
1-4. 페이징: page + size (커서 기반 페이징 옵션 가능)
""")
@GetMapping
public CursorResponseDto<StoreResponseDto.SearchedStore> searchStore(@RequestParam(required = false) String storeName,
@RequestParam(required = false) String region,
@RequestParam(required = false) Long cursorId,
@RequestParam(required = false, defaultValue = "10") Integer size,
@RequestParam String sortType){
return storeService.searchStore(storeName, region, cursorId, size, sortType);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.example.umc9th.domain.store.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

public class StoreResponseDto {
private StoreResponseDto() {}

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class SearchedStore{
private Long id;
private String name;
private String address;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.example.umc9th.domain.store.repository;

import com.querydsl.core.types.dsl.BooleanExpression;
import org.springframework.util.StringUtils;

import static com.example.umc9th.domain.store.entity.QStore.store;

public class StorePredicate {
private StorePredicate() {}

public static BooleanExpression storeNameContains(String queryString) {
if(queryString == null) return null;
String[] keywords = queryString.trim().split("\\s+"); // 공백 여러 칸 이어도 나눔

BooleanExpression be = null;

for(String keyword : keywords) {
if(be == null) {
be = store.name.containsIgnoreCase(keyword); // 대소문자 구분 X
}else{
be = be.and(store.name.containsIgnoreCase(keyword));
}
}

return be;
}

public static BooleanExpression storeAddressContains(String region) {
return StringUtils.hasText(region) ? store.address.contains(region) : null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.example.umc9th.domain.store.repository;

import com.example.umc9th.domain.store.dto.StoreResponseDto;
import com.querydsl.core.types.Predicate;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;

public interface StoreQueryDsl {
Slice<StoreResponseDto.SearchedStore> searchStore(Predicate predicate, Long cursorId, Pageable pageable, String sortType);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
import com.example.umc9th.domain.store.entity.Store;
import org.springframework.data.jpa.repository.JpaRepository;

public interface StoreRepository extends JpaRepository<Store, Long> {
public interface StoreRepository extends JpaRepository<Store, Long>, StoreQueryDsl{

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.example.umc9th.domain.store.repository;

import com.example.umc9th.domain.store.dto.StoreResponseDto;
import com.example.umc9th.domain.store.entity.QStore;
import com.querydsl.core.BooleanBuilder;
// (수정) GroupBy import 제거
import com.querydsl.core.types.OrderSpecifier;
import com.querydsl.core.types.Predicate;
import com.querydsl.core.types.Projections;
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.domain.SliceImpl;

import java.util.List;

import static com.example.umc9th.domain.store.entity.QStore.store;

@Slf4j
@RequiredArgsConstructor
public class StoreRepositoryImpl implements StoreQueryDsl{
private final JPAQueryFactory queryFactory;

@Override
public Slice<StoreResponseDto.SearchedStore> searchStore(Predicate predicate, Long cursorId, Pageable pageable, String sortType) {
BooleanBuilder builder = new BooleanBuilder(predicate);

if(cursorId != null) {
builder.and(store.id.lt(cursorId));
}

int pageSize = pageable.getPageSize();

List<StoreResponseDto.SearchedStore> content = queryFactory
.select(Projections.constructor(StoreResponseDto.SearchedStore.class,
store.id,
store.name,
store.address)
)
.from(store)
.where(builder)
.orderBy(StoreSort.getOrderList(sortType).toArray(new OrderSpecifier[0]))
.limit(pageSize+1)
.fetch();

boolean hasNext = false;
if (content.size() > pageSize) {
content.remove(pageSize);
hasNext = true;
}

return new SliceImpl<>(content, pageable, hasNext);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.example.umc9th.domain.store.repository;

import com.example.umc9th.domain.store.entity.QStore;
import com.querydsl.core.types.OrderSpecifier;
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.NumberExpression;
import com.querydsl.core.types.dsl.StringPath;

import java.util.ArrayList;
import java.util.List;

public class StoreSort {
private static final QStore store = QStore.store;

public static List<OrderSpecifier<?>> getOrderList(String sortType) {
List<OrderSpecifier<?>> orders = new ArrayList<>();

switch(sortType.toLowerCase()) {
case "latest":
orders.add(store.createdAt.desc());
return orders;
case "name":
OrderSpecifier<Integer> sortPriority = new CaseBuilder()
// [수정] mysqlRegexMatches 헬퍼 메서드 사용
.when(mysqlRegexMatches(store.name, "^[가-힣]")).then(1)
.when(mysqlRegexMatches(store.name, "^[A-Z]")).then(2)
.when(mysqlRegexMatches(store.name, "^[a-z]")).then(3)
.otherwise(4)
.asc();

orders.add(sortPriority);
orders.add(store.name.asc());
orders.add(store.createdAt.desc());

return orders;
default:
return null;

}
}

/**
* HQL의 'function()'을 사용해 MySQL의 'REGEXP_LIKE' 함수를 호출합니다.
* MySQL에서 REGEXP_LIKE는 true일 때 1, false일 때 0을 반환합니다.
*
* @param path QStore.store.name
* @param pattern 정규표현식 (예: "^[가-힣]")
* @return BooleanExpression (예: function('REGEXP_LIKE', store.name, '...') = 1)
*/
private static BooleanExpression mysqlRegexMatches(StringPath path, String pattern) {
// 1. HQL 템플릿 생성
NumberExpression<Integer> regexpFunction = Expressions.numberTemplate(
Integer.class,
// [핵심 수정] 'REGEXP' -> 'REGEXP_LIKE'
"function('REGEXP_LIKE', {0}, {1})",
path,
pattern
);

// 2. HQL 파서가 이해할 수 있는 BooleanExpression 반환
// (function(...) = 1)
return regexpFunction.eq(1);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.example.umc9th.domain.store.service;

import com.example.umc9th.domain.store.dto.StoreResponseDto;
import com.example.umc9th.global.dto.CursorResponseDto;

public interface StoreService {
CursorResponseDto<StoreResponseDto.SearchedStore> searchStore(String storeName,
String region,
Long cursorId,
int size,
String sortType);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.example.umc9th.domain.store.service;

import com.example.umc9th.domain.store.dto.StoreResponseDto;
import com.example.umc9th.domain.store.entity.Store;
import com.example.umc9th.domain.store.repository.StorePredicate;
import com.example.umc9th.domain.store.repository.StoreRepository;
import com.example.umc9th.domain.store.repository.StoreRepositoryImpl;
import com.example.umc9th.global.dto.CursorResponseDto;
import com.querydsl.core.BooleanBuilder;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springdoc.core.converters.models.PageableAsQueryParam;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
public class StoreServiceImpl implements StoreService {
private final StoreRepository storeRepository;

@Override
public CursorResponseDto<StoreResponseDto.SearchedStore> searchStore(String storeName, String region, Long cursorId, int size, String sortType) {
BooleanBuilder predicate = new BooleanBuilder();
predicate.and(StorePredicate.storeNameContains(storeName));
predicate.and(StorePredicate.storeAddressContains(region));

Pageable pageable = PageRequest.of(0,size);

Slice<StoreResponseDto.SearchedStore> slice = storeRepository.searchStore(predicate,cursorId,pageable, sortType);

String nextCursor = null;
if (slice.hasNext()) {
List<StoreResponseDto.SearchedStore> content = slice.getContent();
if (!content.isEmpty()) {
nextCursor = content.get(content.size() - 1).getId().toString();
}
}

return new CursorResponseDto<>(slice, nextCursor);
}
}
Loading