Skip to content

Instantly share code, notes, and snippets.

@libetl
Last active September 30, 2023 21:09
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save libetl/4593b47248833c7dc6b89b00f5d5c8f9 to your computer and use it in GitHub Desktop.
Save libetl/4593b47248833c7dc6b89b00f5d5c8f9 to your computer and use it in GitHub Desktop.
Generic search engine for JPA
package com.mycompany.myservice.search.config
import com.mycompany.myservice.search.servlet.SearchEngineDelegateController
import com.fasterxml.jackson.databind.ObjectMapper
import jakarta.persistence.EntityManager
import org.springframework.beans.factory.BeanFactory
import org.springframework.beans.factory.ObjectProvider
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.MessageSource
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Profile
import org.springframework.web.bind.annotation.RequestMethod
import org.springframework.web.servlet.handler.AbstractHandlerMethodMapping
import org.springframework.web.servlet.mvc.method.RequestMappingInfo
import org.springframework.web.util.pattern.PathPatternParser
import java.lang.reflect.Method
import java.util.Optional
import kotlin.reflect.jvm.javaMethod
@EnableConfigurationProperties(GenericSearchInput::class)
@Configuration
class GenericSearchConfiguration {
@Bean
fun searchEngineDelegateController(
entityManager: Optional<EntityManager>,
objectMapper: ObjectMapper = ObjectMapper(),
genericSearchInput: GenericSearchInput = GenericSearchInput.EMPTY,
) = SearchEngineDelegateController(
entityManager.orElse(null),
objectMapper,
genericSearchInput,
)
@Bean
fun addRoutesToTheUrlHandlerMapping(
beanFactory: BeanFactory,
requestMappingHandlerMapping: AbstractHandlerMethodMapping<RequestMappingInfo>,
genericSearchInputOptional: ObjectProvider<GenericSearchInput>,
searchEngineDelegateController: SearchEngineDelegateController,
messageSource: MessageSource,
): Method {
if (genericSearchInputOptional.none()) return SearchEngineDelegateController::serve.javaMethod!!
val genericSearchInput = genericSearchInputOptional.`object`
requestMappingHandlerMapping.registerMapping(
RequestMappingInfo.paths(
*genericSearchInput.pathByEntity.values.map { "${genericSearchInput.rootPath}/$it" }.toTypedArray(),
).methods(RequestMethod.POST)
.consumes("application/json")
.produces("application/json")
.options(
RequestMappingInfo.BuilderConfiguration()
.apply { patternParser = PathPatternParser() },
)
.build(),
searchEngineDelegateController,
searchEngineDelegateController::serve.javaMethod!!,
)
requestMappingHandlerMapping.registerMapping(
RequestMappingInfo.paths(
*genericSearchInput.pathByEntity.values.map { "${genericSearchInput.rootPath}/$it/{category}/{like}" }.toTypedArray(),
).methods(RequestMethod.GET)
.produces("application/json")
.options(
RequestMappingInfo.BuilderConfiguration()
.apply { patternParser = PathPatternParser() },
)
.build(),
searchEngineDelegateController,
searchEngineDelegateController::suggest.javaMethod!!,
)
return SearchEngineDelegateController::suggest.javaMethod!!
}
}
package com.mycompany.myservice.search
import org.springframework.boot.context.properties.ConfigurationProperties
@ConfigurationProperties("generic-search-input")
data class GenericSearchInput(
val rootPath: String = "/generic-search",
val pathByEntity: Map<Class<*>, String> = mapOf(),
) {
companion object {
val EMPTY = GenericSearchInput()
}
}
package com.mycompany.myservice.search
import com.mycompany.myservice.search.SearchRequest.PagingAndSorting.Direction.ASCENDING
import com.fasterxml.jackson.databind.ObjectMapper
import jakarta.persistence.EntityManager
import org.springframework.data.domain.PageRequest
import java.io.InputStream
internal object Search {
fun suggest(
entityManager: EntityManager? = null,
entity: Class<*>,
category: String,
term: String,
): Set<String> {
val criteriaBuilder = entityManager!!.criteriaBuilder
val criteriaQuery = criteriaBuilder.createQuery(String::class.java)
val from = criteriaQuery.from(entity)
return entityManager.createQuery(
criteriaQuery.select(from.get(category))
.where(
criteriaBuilder.like(
from.get(category),
"%${
term.replace(Regex("%"), "")}%",
),
),
)
.setMaxResults(10).resultList.toSet()
}
fun with(
entityManager: EntityManager? = null,
entity: Class<*>,
objectMapper: ObjectMapper = ObjectMapper(),
inputStream: InputStream,
): List<Any> {
val criteriaBuilder = entityManager!!.criteriaBuilder
val criteriaQuery = criteriaBuilder.createQuery(entity)
val from = criteriaQuery.from(entity)
val (query, pagingAndSorting) = objectMapper.readValue(
inputStream,
SearchRequest::class.java,
)
val predicate = query.asPredicate(criteriaBuilder, from)
val criteriaQueryWithPredicate = criteriaQuery.where(predicate)
val orderedQuery = criteriaQueryWithPredicate.orderBy(
pagingAndSorting.sorting.map {
if (it.direction == ASCENDING) {
criteriaBuilder.asc(from.get<Any>(it.category))
} else {
criteriaBuilder.desc(from.get<Any>(it.category))
}
},
)
val typedQuery = entityManager.createQuery(orderedQuery)
val paginatedQuery =
typedQuery.setFirstResult(pagingAndSorting.resultsPerPage.quantity * pagingAndSorting.pageNumber)
.setMaxResults(pagingAndSorting.resultsPerPage.quantity)
return paginatedQuery.resultList
}
}
package com.mycompany.myservice.search.servlet
import com.mycompany.myservice.search.Search
import com.mycompany.myservice.search.GenericSearchInput
import com.fasterxml.jackson.databind.ObjectMapper
import jakarta.persistence.EntityManager
import jakarta.servlet.http.HttpServletRequest
import org.springframework.http.ResponseEntity
import org.springframework.web.util.UriComponentsBuilder
internal class SearchEngineDelegateController(
private val entityManager: EntityManager? = null,
private val objectMapper: ObjectMapper = ObjectMapper(),
private val genericSearchInput: GenericSearchInput = GenericSearchInput.EMPTY,
) {
fun suggest(request: HttpServletRequest): ResponseEntity<Set<String>> {
val (entityName, category, term) =
UriComponentsBuilder.fromUriString(request.requestURI).build().pathSegments.takeLast(3)
val entity = genericSearchInput.pathByEntity.entries.find { it.value == entityName }!!.key
val resultList = Search.suggest(entityManager, entity, category, term)
return ResponseEntity.ok(resultList)
}
fun serve(request: HttpServletRequest): ResponseEntity<List<Any>> {
val path = UriComponentsBuilder.fromUriString(request.requestURI).build().pathSegments.last()
val entity = genericSearchInput.pathByEntity.entries.find { it.value == path }!!.key
val resultList = Search.with(entityManager, entity, objectMapper, request.inputStream)
return ResponseEntity.ok(resultList)
}
}
package com.mycompany.myservice.search
import com.fasterxml.jackson.annotation.JsonValue
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.JsonTokenId
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonDeserializer
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
import com.fasterxml.jackson.databind.exc.InvalidDefinitionException
import com.fasterxml.jackson.databind.node.ObjectNode
@JsonDeserialize(using = SearchRequest.Companion.Deserializer::class)
internal data class SearchRequest(
val query: Query,
val pagingAndSorting: PagingAndSorting,
) {
companion object {
class Deserializer : JsonDeserializer<SearchRequest>() {
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): SearchRequest =
when (p.currentToken.id()) {
JsonTokenId.ID_START_OBJECT -> {
val tree = p.readValueAsTree<ObjectNode>()
if (tree.has("paging_and_sorting")) {
SearchRequest(
pagingAndSorting = p.codec.treeToValue(
tree.get("paging_and_sorting"),
PagingAndSorting::class.java,
),
query = p.codec.treeToValue(tree.get("query"), Query::class.java),
)
} else {
SearchRequest(
pagingAndSorting = PagingAndSorting.NONE,
query = p.codec.treeToValue(tree.get("query"), Query::class.java),
)
}
}
JsonTokenId.ID_START_ARRAY -> SearchRequest(
pagingAndSorting = PagingAndSorting.NONE,
query = p.codec.readValue(p, Query::class.java),
)
else -> throw InvalidDefinitionException.from(
p,
"Generic search input should always be parsed out of an array or an object",
)
}
}
}
data class PagingAndSorting(
val pageNumber: Int = 0,
val resultsPerPage: ResultsPerPage = ResultsPerPage.TEN,
val sorting: List<Order> = listOf(),
) {
companion object {
val NONE = PagingAndSorting()
}
data class Order(
val category: String,
val direction: Direction = Direction.ASCENDING,
)
enum class Direction {
ASCENDING, DESCENDING
}
enum class ResultsPerPage(@get:JsonValue val quantity: Int) {
TEN(10), TWENTY(20), FIFTY(50),
ONE_HUNDRED(100), TWO_HUNDRED(200), FIVE_HUNDRED(500)
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment