-
Notifications
You must be signed in to change notification settings - Fork 0
[NOOK-125] DB 전체 및 내 저장 장소 검색 API #84
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| # NOOK-125 DB 전체 장소 및 내 저장 장소 검색 API | ||
|
|
||
| ## 목적 | ||
|
|
||
| 서비스 DB에 저장된 장소를 이름 또는 주소의 부분 일치로 검색한다. 전체 장소 검색과 로그인 사용자가 저장한 장소 검색을 별도 API로 제공한다. | ||
|
|
||
| ## 범위 | ||
|
|
||
| - `GET /api/v1/places/database/search`: DB 전체 장소 검색 | ||
| - `GET /api/v1/places/my/search`: 현재 사용자가 저장한 장소 검색 | ||
| - 장소명 또는 주소에 `LIKE %query%` 조건 적용 | ||
| - 장소명과 장소 ID 오름차순 정렬 | ||
| - page/size 기반 slice 응답 | ||
| - 장소 기본 정보, 썸네일, 대표 태그와 사용자 저장 여부 응답 | ||
| - Controller HTTP 예제와 계층별 테스트 추가 | ||
|
|
||
| ## 제외 범위 | ||
|
|
||
| - 기존 외부 provider 장소 검색 API 변경 | ||
| - 전문 검색 엔진 또는 n-gram 검색 | ||
| - 데이터베이스 스키마 변경 | ||
|
|
||
| ## 검증 | ||
|
|
||
| - `./gradlew detekt` | ||
| - `./gradlew test` | ||
| - `./gradlew check` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| package org.every.nook.api.application.place | ||
|
|
||
| import org.every.nook.api.application.place.port.SearchAllStoredPlacesPort | ||
|
|
||
| class SearchAllStoredPlacesUseCase(private val port: SearchAllStoredPlacesPort) { | ||
| operator fun invoke(query: Query): StoredPlaceSearchSliceView { | ||
| val keyword = query.keyword.trim() | ||
| require(keyword.isNotEmpty()) { "Place search keyword must not be blank" } | ||
| require(query.page >= 0) { "Place search page is invalid" } | ||
| require(query.size in 1..MAX_PAGE_SIZE) { "Place search page size is invalid" } | ||
| val rows = port.searchAll(query.userId, keyword, Math.multiplyExact(query.page, query.size), query.size + 1) | ||
| return rows.toSlice(query.page, query.size) | ||
| } | ||
|
|
||
| data class Query(val userId: Long, val keyword: String, val page: Int, val size: Int) | ||
| } | ||
|
|
||
| internal const val MAX_STORED_PLACE_SEARCH_PAGE_SIZE = 100 | ||
|
|
||
| internal fun List<StoredPlaceSearchView>.toSlice(page: Int, size: Int): StoredPlaceSearchSliceView = | ||
| StoredPlaceSearchSliceView( | ||
| items = take(size), | ||
| page = page, | ||
| size = size, | ||
| hasNext = this.size > size, | ||
| ) | ||
|
|
||
| private const val MAX_PAGE_SIZE = MAX_STORED_PLACE_SEARCH_PAGE_SIZE | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| package org.every.nook.api.application.place | ||
|
|
||
| import org.every.nook.api.application.place.port.SearchMyStoredPlacesPort | ||
|
|
||
| class SearchMyStoredPlacesUseCase(private val port: SearchMyStoredPlacesPort) { | ||
| operator fun invoke(query: Query): StoredPlaceSearchSliceView { | ||
| val keyword = query.keyword.trim() | ||
| require(keyword.isNotEmpty()) { "Place search keyword must not be blank" } | ||
| require(query.page >= 0) { "Place search page is invalid" } | ||
| require(query.size in 1..MAX_STORED_PLACE_SEARCH_PAGE_SIZE) { "Place search page size is invalid" } | ||
| val rows = port.searchMine(query.userId, keyword, Math.multiplyExact(query.page, query.size), query.size + 1) | ||
| return rows.toSlice(query.page, query.size) | ||
| } | ||
|
|
||
| data class Query(val userId: Long, val keyword: String, val page: Int, val size: Int) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| package org.every.nook.api.application.place | ||
|
|
||
| import java.math.BigDecimal | ||
|
|
||
| data class StoredPlaceSearchView( | ||
| val id: Long, | ||
| val name: String, | ||
| val address: String, | ||
| val category: String?, | ||
| val latitude: BigDecimal, | ||
| val longitude: BigDecimal, | ||
| val thumbnailUrl: String?, | ||
| val tags: List<String>, | ||
| val bookmarked: Boolean, | ||
| ) | ||
|
|
||
| data class StoredPlaceSearchSliceView( | ||
| val items: List<StoredPlaceSearchView>, | ||
| val page: Int, | ||
| val size: Int, | ||
| val hasNext: Boolean, | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package org.every.nook.api.application.place.port | ||
|
|
||
| import org.every.nook.api.application.place.StoredPlaceSearchView | ||
|
|
||
| fun interface SearchAllStoredPlacesPort { | ||
| fun searchAll(userId: Long, keyword: String, offset: Int, limit: Int): List<StoredPlaceSearchView> | ||
| } | ||
|
|
||
| fun interface SearchMyStoredPlacesPort { | ||
| fun searchMine(userId: Long, keyword: String, offset: Int, limit: Int): List<StoredPlaceSearchView> | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| package org.every.nook.api.application.place | ||
|
|
||
| import org.every.nook.api.application.place.port.SearchAllStoredPlacesPort | ||
| import org.every.nook.api.application.place.port.SearchMyStoredPlacesPort | ||
| import java.math.BigDecimal | ||
| import kotlin.test.Test | ||
| import kotlin.test.assertEquals | ||
| import kotlin.test.assertFailsWith | ||
| import kotlin.test.assertTrue | ||
|
|
||
| class SearchStoredPlacesUseCaseTest { | ||
| @Test | ||
| fun `searches all places with trimmed keyword and one extra row`() { | ||
| var captured: List<Any> = emptyList() | ||
| val useCase = SearchAllStoredPlacesUseCase( | ||
| SearchAllStoredPlacesPort { userId, keyword, offset, limit -> | ||
| captured = listOf(userId, keyword, offset, limit) | ||
| listOf(place(1), place(2), place(3)) | ||
| }, | ||
| ) | ||
|
|
||
| val result = useCase(SearchAllStoredPlacesUseCase.Query(7, " 용산 ", 1, 2)) | ||
|
|
||
| assertEquals(listOf(7L, "용산", 2, 3), captured) | ||
| assertEquals(listOf(1L, 2L), result.items.map { it.id }) | ||
| assertTrue(result.hasNext) | ||
| } | ||
|
|
||
| @Test | ||
| fun `searches only my places`() { | ||
| val useCase = SearchMyStoredPlacesUseCase( | ||
| SearchMyStoredPlacesPort { userId, keyword, offset, limit -> | ||
| assertEquals(listOf(7L, "카페", 0, 21), listOf(userId, keyword, offset, limit)) | ||
| listOf(place(1, bookmarked = true)) | ||
| }, | ||
| ) | ||
|
|
||
| val result = useCase(SearchMyStoredPlacesUseCase.Query(7, "카페", 0, 20)) | ||
|
|
||
| assertEquals(1, result.items.size) | ||
| assertTrue(result.items.single().bookmarked) | ||
| } | ||
|
|
||
| @Test | ||
| fun `rejects blank keyword`() { | ||
| val useCase = SearchAllStoredPlacesUseCase(SearchAllStoredPlacesPort { _, _, _, _ -> emptyList() }) | ||
|
|
||
| assertFailsWith<IllegalArgumentException> { | ||
| useCase(SearchAllStoredPlacesUseCase.Query(7, " ", 0, 20)) | ||
| } | ||
| } | ||
|
|
||
| private fun place(id: Long, bookmarked: Boolean = false): StoredPlaceSearchView = StoredPlaceSearchView( | ||
| id = id, | ||
| name = "장소$id", | ||
| address = "서울", | ||
| category = "카페", | ||
| latitude = BigDecimal("37.5"), | ||
| longitude = BigDecimal("127.0"), | ||
| thumbnailUrl = null, | ||
| tags = emptyList(), | ||
| bookmarked = bookmarked, | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| package org.every.nook.api.infrastructure.persistence.place | ||
|
|
||
| import org.springframework.data.jpa.repository.Query | ||
| import org.springframework.data.repository.Repository | ||
| import org.springframework.data.repository.query.Param | ||
| import java.math.BigDecimal | ||
|
|
||
| interface StoredPlaceSearchJpaRepository : Repository<PlaceEntity, Long> { | ||
| @Query( | ||
| value = """ | ||
| SELECT | ||
| p.id AS id, | ||
| p.name AS name, | ||
| p.address AS address, | ||
| p.category AS category, | ||
| p.latitude AS latitude, | ||
| p.longitude AS longitude, | ||
| p.thumbnail_url AS thumbnailUrl, | ||
| CAST(p.representative_tags AS CHAR) AS representativeTags, | ||
| EXISTS ( | ||
| SELECT 1 | ||
| FROM user_place_bookmarks upb | ||
| WHERE upb.user_id = :userId | ||
| AND upb.place_id = p.id | ||
| AND EXISTS ( | ||
| SELECT 1 | ||
| FROM user_saved_posts usp | ||
| INNER JOIN post_places pp ON pp.post_id = usp.post_id | ||
| WHERE usp.user_id = upb.user_id | ||
| AND usp.deleted_at IS NULL | ||
| AND pp.place_id = p.id | ||
| ) | ||
| ) AS bookmarked | ||
| FROM places p | ||
| WHERE (p.name LIKE CONCAT('%', :keyword, '%') OR p.address LIKE CONCAT('%', :keyword, '%')) | ||
| ORDER BY p.name ASC, p.id ASC | ||
| LIMIT :limit OFFSET :offset | ||
| """, | ||
| nativeQuery = true, | ||
| ) | ||
| fun searchAll( | ||
| @Param("userId") userId: Long, | ||
| @Param("keyword") keyword: String, | ||
| @Param("offset") offset: Int, | ||
| @Param("limit") limit: Int, | ||
| ): List<StoredPlaceSearchProjection> | ||
|
|
||
| @Query( | ||
| value = """ | ||
| SELECT | ||
| p.id AS id, | ||
| p.name AS name, | ||
| p.address AS address, | ||
| p.category AS category, | ||
| p.latitude AS latitude, | ||
| p.longitude AS longitude, | ||
| p.thumbnail_url AS thumbnailUrl, | ||
| CAST(p.representative_tags AS CHAR) AS representativeTags, | ||
| TRUE AS bookmarked | ||
| FROM places p | ||
| WHERE (p.name LIKE CONCAT('%', :keyword, '%') OR p.address LIKE CONCAT('%', :keyword, '%')) | ||
| AND EXISTS ( | ||
| SELECT 1 | ||
| FROM user_place_bookmarks upb | ||
| WHERE upb.user_id = :userId | ||
| AND upb.place_id = p.id | ||
| AND EXISTS ( | ||
| SELECT 1 | ||
| FROM user_saved_posts usp | ||
| INNER JOIN post_places pp ON pp.post_id = usp.post_id | ||
| WHERE usp.user_id = upb.user_id | ||
| AND usp.deleted_at IS NULL | ||
| AND pp.place_id = p.id | ||
| ) | ||
| ) | ||
| ORDER BY p.name ASC, p.id ASC | ||
| LIMIT :limit OFFSET :offset | ||
| """, | ||
| nativeQuery = true, | ||
| ) | ||
| fun searchMine( | ||
| @Param("userId") userId: Long, | ||
| @Param("keyword") keyword: String, | ||
| @Param("offset") offset: Int, | ||
| @Param("limit") limit: Int, | ||
| ): List<StoredPlaceSearchProjection> | ||
| } | ||
|
|
||
| interface StoredPlaceSearchProjection { | ||
| val id: Long | ||
| val name: String | ||
| val address: String | ||
| val category: String? | ||
| val latitude: BigDecimal | ||
| val longitude: BigDecimal | ||
| val thumbnailUrl: String? | ||
| val representativeTags: String? | ||
| val bookmarked: Boolean | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For any non-empty result, both native queries expose Useful? React with 👍 / 👎. |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| package org.every.nook.api.infrastructure.persistence.place | ||
|
|
||
| import com.fasterxml.jackson.databind.ObjectMapper | ||
| import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper | ||
| import org.every.nook.api.application.place.StoredPlaceSearchView | ||
| import org.every.nook.api.application.place.port.SearchAllStoredPlacesPort | ||
| import org.every.nook.api.application.place.port.SearchMyStoredPlacesPort | ||
| import org.every.nook.api.domain.place.PlaceTag | ||
| import org.springframework.stereotype.Component | ||
| import org.springframework.transaction.annotation.Transactional | ||
|
|
||
| @Component | ||
| class StoredPlaceSearchPersistenceAdapter( | ||
| private val repository: StoredPlaceSearchJpaRepository, | ||
| private val objectMapper: ObjectMapper = jacksonObjectMapper(), | ||
| ) : SearchAllStoredPlacesPort, | ||
| SearchMyStoredPlacesPort { | ||
| @Transactional(readOnly = true) | ||
| override fun searchAll(userId: Long, keyword: String, offset: Int, limit: Int): List<StoredPlaceSearchView> = | ||
| repository.searchAll(userId, keyword, offset, limit).map { it.toView() } | ||
|
|
||
| @Transactional(readOnly = true) | ||
| override fun searchMine(userId: Long, keyword: String, offset: Int, limit: Int): List<StoredPlaceSearchView> = | ||
| repository.searchMine(userId, keyword, offset, limit).map { it.toView() } | ||
|
|
||
| private fun StoredPlaceSearchProjection.toView(): StoredPlaceSearchView = StoredPlaceSearchView( | ||
| id = id, | ||
| name = name, | ||
| address = address, | ||
| category = category, | ||
| latitude = latitude, | ||
| longitude = longitude, | ||
| thumbnailUrl = thumbnailUrl, | ||
| tags = representativeTags.toDisplayTags(), | ||
| bookmarked = bookmarked, | ||
| ) | ||
|
|
||
| private fun String?.toDisplayTags(): List<String> = if (isNullOrBlank()) { | ||
| emptyList() | ||
| } else { | ||
| objectMapper.readValue(this, Array<String>::class.java).map { PlaceTag.valueOf(it).displayName } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| package org.every.nook.api.infrastructure.persistence.place | ||
|
|
||
| import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper | ||
| import org.mockito.Mockito.mock | ||
| import org.mockito.Mockito.verify | ||
| import org.mockito.Mockito.`when` | ||
| import java.math.BigDecimal | ||
| import kotlin.test.Test | ||
| import kotlin.test.assertEquals | ||
| import kotlin.test.assertTrue | ||
|
|
||
| class StoredPlaceSearchPersistenceAdapterTest { | ||
| private val repository = mock(StoredPlaceSearchJpaRepository::class.java) | ||
| private val adapter = StoredPlaceSearchPersistenceAdapter(repository, jacksonObjectMapper()) | ||
|
|
||
| @Test | ||
| fun `maps all place search result and representative tags`() { | ||
| val projection = projection(bookmarked = false) | ||
| `when`(repository.searchAll(7, "용산", 20, 21)).thenReturn(listOf(projection)) | ||
|
|
||
| val result = adapter.searchAll(7, "용산", 20, 21) | ||
|
|
||
| assertEquals("원동미나리삼겹살", result.single().name) | ||
| assertEquals(listOf("조용한", "혼밥"), result.single().tags) | ||
| assertEquals(false, result.single().bookmarked) | ||
| verify(repository).searchAll(7, "용산", 20, 21) | ||
| } | ||
|
|
||
| @Test | ||
| fun `returns only repository results for my place search`() { | ||
| val projection = projection(bookmarked = true) | ||
| `when`(repository.searchMine(7, "용산", 0, 21)).thenReturn(listOf(projection)) | ||
|
|
||
| val result = adapter.searchMine(7, "용산", 0, 21) | ||
|
|
||
| assertTrue(result.single().bookmarked) | ||
| verify(repository).searchMine(7, "용산", 0, 21) | ||
| } | ||
|
|
||
| private fun projection(bookmarked: Boolean): StoredPlaceSearchProjection = object : StoredPlaceSearchProjection { | ||
| override val id = 17L | ||
| override val name = "원동미나리삼겹살" | ||
| override val address = "서울 용산구" | ||
| override val category = "한식" | ||
| override val latitude = BigDecimal("37.5") | ||
| override val longitude = BigDecimal("127.0") | ||
| override val thumbnailUrl: String? = null | ||
| override val representativeTags = "[\"QUIET\",\"SOLO_DINING\"]" | ||
| override val bookmarked = bookmarked | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a client supplies a nonnegative page whose
page * sizeexceedsInt.MAX_VALUE—for examplepage=21474837&size=100, which passes the controller constraints—Math.multiplyExactthrowsArithmeticException.GlobalExceptionHandlerdoes not classify that as an invalid request, so both new search endpoints return HTTP 500 for validly parsed client input; bound the page/offset or calculate it in a wider type and reject out-of-range values as HTTP 400.Useful? React with 👍 / 👎.