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 @@ -45,7 +45,7 @@ public ApiResponse<CursorSliceResponse<ItemListItemResponse>> getItems(
@Valid @ModelAttribute ItemListParams params
) {
CursorSliceResult<Item> result = itemService.getItems(
params.toCategory(), params.toKeyword(), params.toCursor(), params.sizeOrDefault()
params.toCategory(), params.keyword(), params.toCursor(), params.sizeOrDefault()
);
return ApiResponse.success(CursorSliceResponse.from(result, ItemListItemResponse::from));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,6 @@ public ItemCategory toCategory() {
return ItemCategory.from(category);
}

// 공백뿐인 검색어는 검색하지 않는 것과 같다
public String toKeyword() {
return keyword == null || keyword.isBlank() ? null : keyword.strip();
}

public ItemCursor toCursor() {
return cursor == null ? null : ItemCursor.from(CursorCodec.decode(cursor));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ public interface MemberService {
Member getById(Long id);
List<Member> findAllByIds(List<Long> ids);
Map<Long, Member> getMapByIds(List<Long> ids);
/**
* 이름 또는 학번에 검색어가 포함된 회원 ID를 조회한다. 앞뒤 공백은 지우고 {@code %}·{@code _}는 글자 그대로 찾는다.
* 검색할지 말지는 호출하는 쪽이 정하므로, 검색어가 null이거나 공백뿐이면 일치하는 회원이 없어 빈 목록이다.
*/
List<Long> searchIdsByKeyword(String keyword);
Member updateProfile(Long id, MemberProfileCommand command);
Member registerOrUpdateByStudentId(MemberProfileCommand command);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public interface ItemRepository {
* 물품을 이름 오름차순(같으면 식별자 오름차순)으로 커서 페이지 단위로 조회한다.
*
* @param category 카테고리. null이면 전체
* @param keyword 이름에 포함된 검색어. null이면 검색하지 않는다
* @param keyword 이름에 포함된 검색어. null이거나 공백이면 검색하지 않는다. 앞뒤 공백은 지우고 {@code %}·{@code _}는 글자 그대로 검색한다
* @param cursor 이전 페이지의 마지막 항목. null이면 첫 페이지
*/
CursorSliceResult<Item> findSlice(ItemCategory category, String keyword, ItemCursor cursor, int size);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public interface ItemService {
* 물품 목록을 이름순 커서 페이지로 조회한다.
*
* @param category 카테고리. null이면 전체
* @param keyword 이름 검색어. null이면 검색하지 않는다
* @param keyword 이름 검색어. null이거나 공백이면 검색하지 않는다
* @param cursor 이전 페이지의 마지막 항목. null이면 첫 페이지
*/
CursorSliceResult<Item> getItems(ItemCategory category, String keyword, ItemCursor cursor, int size);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
import lombok.NoArgsConstructor;

/**
* {@code LIKE} 검색어 이스케이프 유틸.
* {@code LIKE} 검색어 유틸. 검색어를 정리하고 이스케이프한다.
* <p>
* 사용자 입력을 {@code CONCAT('%', :keyword, '%')}로 그대로 붙이면 검색어 안의 {@code %}·{@code _}가 와일드카드로
* 동작해서 {@code %}만 입력해도 모든 행이 걸린다. 검색어를 {@link #escape(String)}로 이스케이프해서 넘기고, 쿼리는
* 동작해서 {@code %}만 입력해도 모든 행이 걸린다. 검색어를 {@link #escape(String)}로 정리·이스케이프해서 넘기고, 쿼리는
* {@code LIKE CONCAT('%', :keyword, '%') ESCAPE '!'}로 받는다.
* <p>
* 이스케이프 문자는 {@code \}가 아니라 {@code !}다. MySQL은 문자열 리터럴에서 {@code \}를 이스케이프로 먹기 때문에
Expand All @@ -22,18 +22,19 @@ public final class LikePatterns {
public static final char ESCAPE = '!';

/**
* 검색어 안의 {@code LIKE} 메타문자({@code %}, {@code _})와 이스케이프 문자를 글자 그대로 검색되도록 이스케이프한다.
* 공백 제거나 "검색어 없음" 판단은 하지 않는다.
* 검색어를 {@code LIKE}에 넘길 수 있게 정리하고 이스케이프한다. 앞뒤 공백을 지우고, 검색어 안의 {@code %}·{@code _}와
* 이스케이프 문자를 글자 그대로 검색되도록 앞에 이스케이프 문자를 붙인다.
*
* @return 이스케이프한 검색어. {@code raw}가 null이면 null
* @return 정리·이스케이프한 검색어. {@code raw}가 null이거나 공백뿐이면 "검색 없음"이라 null
*/
public static String escape(String raw) {
if (raw == null) {
if (raw == null || raw.isBlank()) {
return null;
}

StringBuilder escaped = new StringBuilder(raw.length() + 4);
for (char c : raw.toCharArray()) {
String keyword = raw.strip();
StringBuilder escaped = new StringBuilder(keyword.length() + 4);
for (char c : keyword.toCharArray()) {
if (c == ESCAPE || c == '%' || c == '_') {
escaped.append(ESCAPE);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ public interface MemberJpaRepository extends JpaRepository<MemberJpaEntity, Long

List<MemberJpaEntity> findAllByIdInAndIsDeletedFalse(List<Long> ids);

// keyword는 LikePatterns.escape로 이스케이프한 검색어다. 쿼리의 ESCAPE 문자는 LikePatterns.ESCAPE와 같아야 한다
@Query("""
SELECT m.id FROM MemberJpaEntity m
WHERE m.isDeleted = false
AND (m.name LIKE CONCAT('%', :keyword, '%') OR m.studentId LIKE CONCAT('%', :keyword, '%'))
AND (m.name LIKE CONCAT('%', :keyword, '%') ESCAPE '!'
OR m.studentId LIKE CONCAT('%', :keyword, '%') ESCAPE '!')
""")
List<Long> searchIdsByKeyword(@Param("keyword") String keyword);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import java.util.Optional;
import kr.ac.kookmin.stream.common.BusinessException;
import kr.ac.kookmin.stream.db.common.ConstraintViolationUtil;
import kr.ac.kookmin.stream.db.common.LikePatterns;
import kr.ac.kookmin.stream.member.domain.member.domain.Member;
import kr.ac.kookmin.stream.member.domain.member.domain.MemberErrorCode;
import kr.ac.kookmin.stream.member.domain.member.repository.MemberRepository;
Expand Down Expand Up @@ -46,7 +47,7 @@ public List<Member> findAllByIds(List<Long> ids) {

@Override
public List<Long> searchIdsByKeyword(String keyword) {
return memberJpaRepository.searchIdsByKeyword(keyword);
return memberJpaRepository.searchIdsByKeyword(LikePatterns.escape(keyword));
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@

public interface ItemJpaRepository extends JpaRepository<ItemJpaEntity, Long> {

// 커서가 없으면 첫 페이지, 있으면 그 다음부터 조회한다. keyword는 이름 부분 일치 검색이다.
// LIKE 대신 LOCATE를 써서 %·_ 같은 문자도 와일드카드가 아닌 글자 그대로 검색된다
// 커서가 없으면 첫 페이지, 있으면 그 다음부터 조회한다. keyword는 이름 부분 일치 검색이며 LikePatterns.escape로
// 이스케이프한 값을 받는다. 쿼리의 ESCAPE 문자는 LikePatterns.ESCAPE와 같아야 한다
@Query("""
SELECT i FROM ItemJpaEntity i
WHERE (:category IS NULL OR i.category = :category)
AND (:keyword IS NULL OR LOCATE(:keyword, i.name) > 0)
AND (:keyword IS NULL OR i.name LIKE CONCAT('%', :keyword, '%') ESCAPE '!')
AND (:cursorName IS NULL OR i.name > :cursorName OR (i.name = :cursorName AND i.id > :cursorId))
ORDER BY i.name ASC, i.id ASC
""")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import java.util.List;
import java.util.Optional;
import kr.ac.kookmin.stream.common.CursorSliceResult;
import kr.ac.kookmin.stream.db.common.LikePatterns;
import kr.ac.kookmin.stream.welfare.domain.rental.domain.Item;
import kr.ac.kookmin.stream.welfare.domain.rental.domain.ItemCategory;
import kr.ac.kookmin.stream.welfare.domain.rental.domain.ItemCursor;
Expand All @@ -22,7 +23,7 @@ public class ItemRepositoryImpl implements ItemRepository {
public CursorSliceResult<Item> findSlice(ItemCategory category, String keyword, ItemCursor cursor, int size) {
Pageable pageable = Pageable.ofSize(size + 1);
List<ItemJpaEntity> entities = itemJpaRepository.findSlice(
category, keyword,
category, LikePatterns.escape(keyword),
cursor == null ? null : cursor.name(),
cursor == null ? null : cursor.id(),
pageable
Expand Down