feat(api): search series by regex

This commit is contained in:
Gauthier Roebroeck 2021-07-21 11:01:29 +08:00
parent c4a439276a
commit 1fe55809a1
14 changed files with 183 additions and 12 deletions

View file

@ -87,7 +87,7 @@ dependencies {
// While waiting for https://github.com/xerial/sqlite-jdbc/pull/491 and https://github.com/xerial/sqlite-jdbc/pull/494
// runtimeOnly("org.xerial:sqlite-jdbc:3.32.3.2")
// jooqGenerator("org.xerial:sqlite-jdbc:3.32.3.2")
runtimeOnly("com.github.gotson:sqlite-jdbc:3.32.3.6")
implementation("com.github.gotson:sqlite-jdbc:3.32.3.6")
jooqGenerator("com.github.gotson:sqlite-jdbc:3.32.3.6")
testImplementation("org.springframework.boot:spring-boot-starter-test") {

View file

@ -4,15 +4,21 @@ open class SeriesSearch(
val libraryIds: Collection<String>? = null,
val collectionIds: Collection<String>? = null,
val searchTerm: String? = null,
val searchRegex: Pair<String, SearchField>? = null,
val metadataStatus: Collection<SeriesMetadata.Status>? = null,
val publishers: Collection<String>? = null,
val deleted: Boolean? = null,
)
) {
enum class SearchField {
NAME, TITLE, TITLE_SORT
}
}
class SeriesSearchWithReadProgress(
libraryIds: Collection<String>? = null,
collectionIds: Collection<String>? = null,
searchTerm: String? = null,
searchRegex: Pair<String, SearchField>? = null,
metadataStatus: Collection<SeriesMetadata.Status>? = null,
publishers: Collection<String>? = null,
deleted: Boolean? = null,
@ -27,6 +33,7 @@ class SeriesSearchWithReadProgress(
libraryIds = libraryIds,
collectionIds = collectionIds,
searchTerm = searchTerm,
searchRegex = searchRegex,
metadataStatus = metadataStatus,
publishers = publishers,
deleted = deleted,

View file

@ -1,5 +1,6 @@
package org.gotson.komga.infrastructure.datasource
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import org.gotson.komga.infrastructure.configuration.KomgaProperties
import org.springframework.boot.jdbc.DataSourceBuilder
@ -15,12 +16,20 @@ class DataSourcesConfiguration(
@Bean("sqliteDataSource")
@Primary
fun sqliteDataSource(): DataSource = (
DataSourceBuilder.create()
fun sqliteDataSource(): DataSource {
val sqliteUdfDataSource = DataSourceBuilder.create()
.driverClassName("org.sqlite.JDBC")
.url("jdbc:sqlite:${komgaProperties.database.file}?foreign_keys=on;")
.type(HikariDataSource::class.java)
.build() as HikariDataSource
.type(SqliteUdfDataSource::class.java)
.build()
return HikariDataSource(
HikariConfig().apply {
dataSource = sqliteUdfDataSource
poolName = "SqliteUdfPool"
maximumPoolSize = 1
}
)
.apply { maximumPoolSize = 1 }
}
}

View file

@ -0,0 +1,33 @@
package org.gotson.komga.infrastructure.datasource
import mu.KotlinLogging
import org.springframework.jdbc.datasource.SimpleDriverDataSource
import org.sqlite.Function
import org.sqlite.SQLiteConnection
import java.sql.Connection
private val log = KotlinLogging.logger {}
class SqliteUdfDataSource : SimpleDriverDataSource() {
override fun getConnection(): Connection =
super.getConnection().also { createUdfRegexp(it as SQLiteConnection) }
override fun getConnection(username: String, password: String): Connection =
super.getConnection(username, password).also { createUdfRegexp(it as SQLiteConnection) }
private fun createUdfRegexp(connection: SQLiteConnection) {
log.debug { "Adding custom REGEXP function" }
Function.create(
connection, "REGEXP",
object : Function() {
override fun xFunc() {
val regexp = (value_text(0) ?: "").toRegex(RegexOption.IGNORE_CASE)
val text = value_text(1) ?: ""
result(if (regexp.containsMatchIn(text)) 1 else 0)
}
}
)
}
}

View file

@ -127,6 +127,7 @@ class SeriesDao(
if (!libraryIds.isNullOrEmpty()) c = c.and(s.LIBRARY_ID.`in`(libraryIds))
if (!collectionIds.isNullOrEmpty()) c = c.and(cs.COLLECTION_ID.`in`(collectionIds))
searchTerm?.let { c = c.and(d.TITLE.containsIgnoreCase(it)) }
searchRegex?.let { c = c.and((it.second.toColumn()).likeRegex(it.first)) }
if (!metadataStatus.isNullOrEmpty()) c = c.and(d.STATUS.`in`(metadataStatus))
if (!publishers.isNullOrEmpty()) c = c.and(DSL.lower(d.PUBLISHER).`in`(publishers.map { it.lowercase() }))
if (deleted == true) c = c.and(s.DELETED_DATE.isNotNull)
@ -135,6 +136,13 @@ class SeriesDao(
return c
}
private fun SeriesSearch.SearchField.toColumn() =
when (this) {
SeriesSearch.SearchField.NAME -> s.NAME
SeriesSearch.SearchField.TITLE -> d.TITLE
SeriesSearch.SearchField.TITLE_SORT -> d.TITLE_SORT
}
private fun SeriesRecord.toDomain() =
Series(
name = name,

View file

@ -1,6 +1,7 @@
package org.gotson.komga.infrastructure.jooq
import org.gotson.komga.domain.model.ReadStatus
import org.gotson.komga.domain.model.SeriesSearch
import org.gotson.komga.domain.model.SeriesSearchWithReadProgress
import org.gotson.komga.infrastructure.web.toFilePath
import org.gotson.komga.interfaces.rest.dto.AuthorDto
@ -205,6 +206,7 @@ class SeriesDtoDao(
if (!libraryIds.isNullOrEmpty()) c = c.and(s.LIBRARY_ID.`in`(libraryIds))
if (!collectionIds.isNullOrEmpty()) c = c.and(cs.COLLECTION_ID.`in`(collectionIds))
searchTerm?.let { c = c.and(d.TITLE.containsIgnoreCase(it)) }
searchRegex?.let { c = c.and((it.second.toColumn()).likeRegex(it.first)) }
if (!metadataStatus.isNullOrEmpty()) c = c.and(d.STATUS.`in`(metadataStatus))
if (!publishers.isNullOrEmpty()) c = c.and(lower(d.PUBLISHER).`in`(publishers.map { it.lowercase() }))
if (deleted == true) c = c.and(s.DELETED_DATE.isNotNull)
@ -242,6 +244,13 @@ class SeriesDtoDao(
return c
}
private fun SeriesSearch.SearchField.toColumn() =
when (this) {
SeriesSearch.SearchField.NAME -> s.NAME
SeriesSearch.SearchField.TITLE -> d.TITLE
SeriesSearch.SearchField.TITLE_SORT -> d.TITLE_SORT
}
private fun SeriesSearchWithReadProgress.toJoinConditions() =
JoinConditions(
genre = !genres.isNullOrEmpty(),

View file

@ -2,4 +2,4 @@ package org.gotson.komga.infrastructure.web
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.VALUE_PARAMETER)
annotation class Authors()
annotation class Authors

View file

@ -0,0 +1,7 @@
package org.gotson.komga.infrastructure.web
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.VALUE_PARAMETER)
annotation class DelimitedPair(
val parameterName: String
)

View file

@ -0,0 +1,31 @@
package org.gotson.komga.infrastructure.web
import org.springframework.core.MethodParameter
import org.springframework.web.bind.support.WebDataBinderFactory
import org.springframework.web.context.request.NativeWebRequest
import org.springframework.web.method.support.HandlerMethodArgumentResolver
import org.springframework.web.method.support.ModelAndViewContainer
class DelimitedPairHandlerMethodArgumentResolver : HandlerMethodArgumentResolver {
override fun supportsParameter(parameter: MethodParameter): Boolean =
parameter.getParameterAnnotation(DelimitedPair::class.java) != null
override fun resolveArgument(
parameter: MethodParameter,
mavContainer: ModelAndViewContainer?,
webRequest: NativeWebRequest,
binderFactory: WebDataBinderFactory?
): Pair<String, String>? {
val paramName = parameter.getParameterAnnotation(DelimitedPair::class.java)?.parameterName ?: return null
val param = webRequest.getParameterValues(paramName) ?: return null
// Single empty parameter, e.g "search="
if (param.size == 1 && param[0].isNullOrBlank()) return null
return parseParameterIntoPairs(param.first())
}
private fun parseParameterIntoPairs(source: String, delimiter: String = ","): Pair<String, String>? =
if (!source.contains(delimiter)) null
else Pair(source.substringBeforeLast(delimiter), source.substringAfterLast(delimiter))
}

View file

@ -81,6 +81,7 @@ class WebMvcConfiguration : WebMvcConfigurer {
override fun addArgumentResolvers(resolvers: MutableList<HandlerMethodArgumentResolver>) {
resolvers.add(AuthorsHandlerMethodArgumentResolver())
resolvers.add(DelimitedPairHandlerMethodArgumentResolver())
}
}

View file

@ -2,6 +2,8 @@ package org.gotson.komga.interfaces.rest
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.Parameters
import io.swagger.v3.oas.annotations.enums.ParameterIn
import io.swagger.v3.oas.annotations.media.Content
import io.swagger.v3.oas.annotations.media.Schema
import io.swagger.v3.oas.annotations.responses.ApiResponse
@ -20,6 +22,7 @@ import org.gotson.komga.domain.model.ROLE_ADMIN
import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD
import org.gotson.komga.domain.model.ReadStatus
import org.gotson.komga.domain.model.SeriesMetadata
import org.gotson.komga.domain.model.SeriesSearch
import org.gotson.komga.domain.model.SeriesSearchWithReadProgress
import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.SeriesCollectionRepository
@ -33,6 +36,7 @@ import org.gotson.komga.infrastructure.swagger.AuthorsAsQueryParam
import org.gotson.komga.infrastructure.swagger.PageableAsQueryParam
import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam
import org.gotson.komga.infrastructure.web.Authors
import org.gotson.komga.infrastructure.web.DelimitedPair
import org.gotson.komga.interfaces.rest.dto.BookDto
import org.gotson.komga.interfaces.rest.dto.CollectionDto
import org.gotson.komga.interfaces.rest.dto.SeriesDto
@ -93,10 +97,17 @@ class SeriesController(
@PageableAsQueryParam
@AuthorsAsQueryParam
@Parameters(
Parameter(
description = "Search by regex criteria, in the form: regex,field. Supported fields are TITLE and TITLE_SORT.",
`in` = ParameterIn.QUERY, name = "search_regex", schema = Schema(type = "string")
)
)
@GetMapping
fun getAllSeries(
@AuthenticationPrincipal principal: KomgaPrincipal,
@RequestParam(name = "search", required = false) searchTerm: String?,
@Parameter(hidden = true) @DelimitedPair("search_regex") searchRegex: Pair<String, String>?,
@RequestParam(name = "library_id", required = false) libraryIds: List<String>?,
@RequestParam(name = "collection_id", required = false) collectionIds: List<String>?,
@RequestParam(name = "status", required = false) metadataStatus: List<SeriesMetadata.Status>?,
@ -128,6 +139,13 @@ class SeriesController(
libraryIds = principal.user.getAuthorizedLibraryIds(libraryIds),
collectionIds = collectionIds,
searchTerm = searchTerm,
searchRegex = searchRegex?.let {
when (it.second.lowercase()) {
"title" -> Pair(it.first, SeriesSearch.SearchField.TITLE)
"title_sort" -> Pair(it.first, SeriesSearch.SearchField.TITLE_SORT)
else -> null
}
},
metadataStatus = metadataStatus,
readStatus = readStatus,
publishers = publishers,

View file

@ -21,14 +21,15 @@ logging:
name: komga-dev.log
level:
org.apache.activemq.audit.message: WARN
org.gotson.komga: DEBUG
# org.jooq: DEBUG
# web: DEBUG
org.gotson.komga: DEBUG
# com.zaxxer.hikari: DEBUG
# org.springframework.jms: DEBUG
# org.springframework.security.web.FilterChainProxy: DEBUG
logback:
rollingpolicy:
max-history: 1
# org.springframework.jms: DEBUG
# org.springframework.security.web.FilterChainProxy: DEBUG
management.metrics.export.influx:
# enabled: true

View file

@ -195,7 +195,7 @@ class SeriesDaoTest(
}
@Test
fun `given existing series when searching then results is returned`() {
fun `given existing series when searching then result is returned`() {
val series = Series(
name = "Series",
url = URL("file://series"),
@ -213,6 +213,21 @@ class SeriesDaoTest(
assertThat(found).hasSize(1)
}
@Test
fun `given existing series when searching by regex then result is returned`() {
val series = Series(
name = "my Series",
url = URL("file://series"),
fileLastModified = LocalDateTime.now(),
libraryId = library.id
)
seriesDao.insert(series)
assertThat(seriesDao.findAll(SeriesSearch(searchRegex = Pair("^my", SeriesSearch.SearchField.NAME)))).hasSize(1)
assertThat(seriesDao.findAll(SeriesSearch(searchRegex = Pair("ries$", SeriesSearch.SearchField.NAME)))).hasSize(1)
assertThat(seriesDao.findAll(SeriesSearch(searchRegex = Pair("series", SeriesSearch.SearchField.NAME)))).hasSize(1)
}
@Test
fun `given existing series when finding by libraryId then series are returned`() {
val series = Series(

View file

@ -86,6 +86,38 @@ class SeriesControllerTest(
seriesLifecycle.deleteMany(seriesRepository.findAll())
}
@Nested
inner class Search {
@Test
@WithMockCustomUser
fun `given series when searching by regex then series are found`() {
val alphaC = seriesLifecycle.createSeries(makeSeries("TheAlpha", libraryId = library.id))
seriesMetadataRepository.findById(alphaC.id).let {
seriesMetadataRepository.update(it.copy(titleSort = "Alpha, The"))
}
seriesLifecycle.createSeries(makeSeries("TheBeta", libraryId = library.id))
mockMvc.get("/api/v1/series") {
param("search_regex", "a$,title_sort")
}
.andExpect {
status { isOk() }
jsonPath("$.content.length()") { value(1) }
jsonPath("$.content[0].metadata.title") { value("TheBeta") }
}
mockMvc.get("/api/v1/series") {
param("search_regex", "^the,title")
}
.andExpect {
status { isOk() }
jsonPath("$.content.length()") { value(2) }
jsonPath("$.content[0].metadata.title") { value("TheAlpha") }
jsonPath("$.content[1].metadata.title") { value("TheBeta") }
}
}
}
@Nested
inner class SeriesSort {
@Test