feat(api): add new series list API using search condition criteria DSL

add book search condition criteria DSL
This commit is contained in:
Gauthier Roebroeck 2024-11-18 17:22:33 +08:00
parent 7fa42f5899
commit 3bfc7981e5
54 changed files with 4276 additions and 953 deletions

View file

@ -4,10 +4,12 @@ import io.github.oshai.kotlinlogging.KotlinLogging
import org.gotson.komga.domain.model.Book
import org.gotson.komga.domain.model.BookMetadataPatchCapability
import org.gotson.komga.domain.model.BookPageNumbered
import org.gotson.komga.domain.model.BookSearch
import org.gotson.komga.domain.model.CopyMode
import org.gotson.komga.domain.model.Library
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.SearchCondition
import org.gotson.komga.domain.model.SearchContext
import org.gotson.komga.domain.model.SearchOperator
import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.service.BookConverter
import org.gotson.komga.infrastructure.jooq.UnpagedSorted
@ -43,10 +45,14 @@ class TaskEmitter(
fun analyzeUnknownAndOutdatedBooks(library: Library) {
bookRepository
.findAll(
BookSearch(
libraryIds = listOf(library.id),
mediaStatus = listOf(Media.Status.UNKNOWN, Media.Status.OUTDATED),
SearchCondition.AllOfBook(
SearchCondition.LibraryId(SearchOperator.Is(library.id)),
SearchCondition.AnyOfBook(
SearchCondition.MediaStatus(SearchOperator.Is(Media.Status.UNKNOWN)),
SearchCondition.MediaStatus(SearchOperator.Is(Media.Status.OUTDATED)),
),
),
SearchContext.empty(),
UnpagedSorted(Sort.by(Sort.Order.asc("seriesId"), Sort.Order.asc("number"))),
)
.content

View file

@ -1,34 +1,9 @@
package org.gotson.komga.domain.model
import java.time.LocalDate
import com.fasterxml.jackson.annotation.JsonInclude
open class BookSearch(
val libraryIds: Collection<String>? = null,
val seriesIds: Collection<String>? = null,
val searchTerm: String? = null,
val mediaStatus: Collection<Media.Status>? = null,
val mediaProfile: Collection<MediaProfile>? = null,
val deleted: Boolean? = null,
val releasedAfter: LocalDate? = null,
@JsonInclude(JsonInclude.Include.NON_NULL)
data class BookSearch(
val condition: SearchCondition.Book? = null,
val fullTextSearch: String? = null,
)
class BookSearchWithReadProgress(
libraryIds: Collection<String>? = null,
seriesIds: Collection<String>? = null,
searchTerm: String? = null,
mediaStatus: Collection<Media.Status>? = null,
mediaProfile: Collection<MediaProfile>? = null,
deleted: Boolean? = null,
releasedAfter: LocalDate? = null,
val tags: Collection<String>? = null,
val readStatus: Collection<ReadStatus>? = null,
val authors: Collection<Author>? = null,
) : BookSearch(
libraryIds = libraryIds,
seriesIds = seriesIds,
searchTerm = searchTerm,
mediaStatus = mediaStatus,
mediaProfile = mediaProfile,
deleted = deleted,
releasedAfter = releasedAfter,
)

View file

@ -42,7 +42,7 @@ data class KomgaUser(
/**
* Return the list of LibraryIds this user is authorized to view, intersecting the provided list of LibraryIds.
*
* @param libraryIds an optional list of LibraryIds to filter on
* @return a list of authorised LibraryIds, or null if the user is authorized to see all libraries
*/
fun getAuthorizedLibraryIds(libraryIds: Collection<String>?): Collection<String>? =

View file

@ -0,0 +1,157 @@
package org.gotson.komga.domain.model
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.annotation.JsonTypeInfo
class SearchCondition {
@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION)
sealed interface Book
@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION)
sealed interface Series
data class AnyOfBook(
@JsonProperty("anyOf")
val conditions: List<Book>,
) : Book {
constructor(vararg args: Book) : this(args.toList())
}
data class AllOfBook(
@JsonProperty("allOf")
val conditions: List<Book>,
) : Book {
constructor(vararg args: Book) : this(args.toList())
}
data class AnyOfSeries(
@JsonProperty("anyOf")
val conditions: List<Series>,
) : Series {
constructor(vararg args: Series) : this(args.toList())
}
data class AllOfSeries(
@JsonProperty("allOf")
val conditions: List<Series>,
) : Series {
constructor(vararg args: Series) : this(args.toList())
}
data class LibraryId(
@JsonProperty("libraryId")
val operator: SearchOperator.Equality<String>,
) : Book, Series
data class CollectionId(
@JsonProperty("collectionId")
val operator: SearchOperator.Equality<String>,
) : Series
data class ReadListId(
@JsonProperty("readListId")
val operator: SearchOperator.Equality<String>,
) : Book
data class SeriesId(
@JsonProperty("seriesId")
val operator: SearchOperator.Equality<String>,
) : Book
data class Deleted(
@JsonProperty("deleted")
val operator: SearchOperator.Boolean,
) : Book, Series
data class Complete(
@JsonProperty("complete")
val operator: SearchOperator.Boolean,
) : Series
data class OneShot(
@JsonProperty("oneShot")
val operator: SearchOperator.Boolean,
) : Book, Series
data class Title(
@JsonProperty("title")
val operator: SearchOperator.StringOp,
) : Book, Series
data class TitleSort(
@JsonProperty("titleSort")
val operator: SearchOperator.StringOp,
) : Series
data class ReleaseDate(
@JsonProperty("releaseDate")
val operator: SearchOperator.Date,
) : Book, Series
data class NumberSort(
@JsonProperty("numberSort")
val operator: SearchOperator.Numeric<Float>,
) : Book
data class Tag(
@JsonProperty("tag")
val operator: SearchOperator.Equality<String>,
) : Book, Series
data class SharingLabel(
@JsonProperty("sharingLabel")
val operator: SearchOperator.Equality<String>,
) : Series
data class Publisher(
@JsonProperty("publisher")
val operator: SearchOperator.Equality<String>,
) : Series
data class Language(
@JsonProperty("language")
val operator: SearchOperator.Equality<String>,
) : Series
data class Genre(
@JsonProperty("genre")
val operator: SearchOperator.Equality<String>,
) : Series
data class AgeRating(
@JsonProperty("ageRating")
val operator: SearchOperator.NumericNullable<Int>,
) : Series
data class ReadStatus(
@JsonProperty("readStatus")
val operator: SearchOperator.Equality<org.gotson.komga.domain.model.ReadStatus>,
) : Book, Series
data class MediaStatus(
@JsonProperty("mediaStatus")
val operator: SearchOperator.Equality<Media.Status>,
) : Book
data class SeriesStatus(
@JsonProperty("seriesStatus")
val operator: SearchOperator.Equality<SeriesMetadata.Status>,
) : Series
data class MediaProfile(
@JsonProperty("mediaProfile")
val operator: SearchOperator.Equality<org.gotson.komga.domain.model.MediaProfile>,
) : Book
data class Author(
@JsonProperty("author")
val operator: SearchOperator.Equality<AuthorMatch>,
) : Book, Series
@JsonInclude(JsonInclude.Include.NON_NULL)
data class AuthorMatch(
val name: String? = null,
val role: String? = null,
)
}

View file

@ -0,0 +1,11 @@
package org.gotson.komga.domain.model
class SearchContext private constructor(val userId: String?, val restrictions: ContentRestrictions, val libraryIds: Collection<String>?) {
constructor(user: KomgaUser?) : this(user?.id, user?.restrictions ?: ContentRestrictions(), user?.getAuthorizedLibraryIds(null))
companion object {
fun empty() = SearchContext(null)
fun ofAnonymousUser() = SearchContext("UNUSED", ContentRestrictions(), null)
}
}

View file

@ -0,0 +1,7 @@
package org.gotson.komga.domain.model
@Deprecated("use SearchOperator.BeginsWith instead")
enum class SearchField {
TITLE,
TITLE_SORT,
}

View file

@ -0,0 +1,130 @@
package org.gotson.komga.domain.model
import com.fasterxml.jackson.annotation.JsonTypeInfo
import com.fasterxml.jackson.annotation.JsonTypeName
import java.time.Duration
import java.time.ZonedDateTime
class SearchOperator {
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.PROPERTY,
property = "operator",
)
sealed interface Equality<T>
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.PROPERTY,
property = "operator",
)
sealed interface StringOp
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.PROPERTY,
property = "operator",
)
sealed interface Numeric<T>
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.PROPERTY,
property = "operator",
)
sealed interface NumericNullable<T>
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.PROPERTY,
property = "operator",
)
sealed interface Date
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.PROPERTY,
property = "operator",
)
sealed interface Boolean
@JsonTypeName("is")
data class Is<T>(val value: T) : Equality<T>, StringOp, Numeric<T>, NumericNullable<T>
@JsonTypeName("isNot")
data class IsNot<T>(val value: T) : Equality<T>, StringOp, Numeric<T>, NumericNullable<T>
@JsonTypeName("contains")
data class Contains(val value: String) : StringOp
@JsonTypeName("doesNotContain")
data class DoesNotContain(val value: String) : StringOp
@JsonTypeName("beginsWith")
data class BeginsWith(val value: String) : StringOp
@JsonTypeName("doesNotBeginWith")
data class DoesNotBeginWith(val value: String) : StringOp
@JsonTypeName("endsWith")
data class EndsWith(val value: String) : StringOp
@JsonTypeName("doesNotEndWith")
data class DoesNotEndWith(val value: String) : StringOp
@JsonTypeName("greaterThan")
data class GreaterThan<T>(val value: T) : Numeric<T>, NumericNullable<T>
@JsonTypeName("lessThan")
data class LessThan<T>(val value: T) : Numeric<T>, NumericNullable<T>
@JsonTypeName("before")
data class Before(val dateTime: ZonedDateTime) : Date
@JsonTypeName("after")
data class After(val dateTime: ZonedDateTime) : Date
@JsonTypeName("isInTheLast")
data class IsInTheLast(val duration: Duration) : Date
@JsonTypeName("isNotInTheLast")
data class IsNotInTheLast(val duration: Duration) : Date
@JsonTypeName("isTrue")
data object IsTrue : Boolean
@JsonTypeName("isFalse")
data object IsFalse : Boolean
@JsonTypeName("isNull")
data object IsNull : Date
@JsonTypeName("isNotNull")
data object IsNotNull : Date
@JsonTypeName("isNull")
class IsNullT<T> : NumericNullable<T> {
override fun equals(other: Any?): kotlin.Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
return true
}
override fun hashCode(): Int {
return javaClass.hashCode()
}
}
@JsonTypeName("isNotNull")
class IsNotNullT<T> : NumericNullable<T> {
override fun equals(other: Any?): kotlin.Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
return true
}
override fun hashCode(): Int {
return javaClass.hashCode()
}
}
}

View file

@ -1,49 +1,13 @@
package org.gotson.komga.domain.model
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,
val complete: Boolean? = null,
val oneshot: Boolean? = null,
) {
enum class SearchField {
NAME,
TITLE,
TITLE_SORT,
}
}
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonInclude
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,
complete: Boolean? = null,
oneshot: Boolean? = null,
val languages: Collection<String>? = null,
val genres: Collection<String>? = null,
val tags: Collection<String>? = null,
val ageRatings: Collection<Int?>? = null,
val releaseYears: Collection<String>? = null,
val readStatus: Collection<ReadStatus>? = null,
val authors: Collection<Author>? = null,
val sharingLabels: Collection<String>? = null,
) : SeriesSearch(
libraryIds = libraryIds,
collectionIds = collectionIds,
searchTerm = searchTerm,
searchRegex = searchRegex,
metadataStatus = metadataStatus,
publishers = publishers,
deleted = deleted,
complete = complete,
oneshot = oneshot,
)
@JsonInclude(JsonInclude.Include.NON_NULL)
data class SeriesSearch(
val condition: SearchCondition.Series? = null,
val fullTextSearch: String? = null,
@JsonIgnore
@Deprecated("Used for backward compatibility only, use SearchOperator.BeginsWith instead")
val regexSearch: Pair<String, SearchField>? = null,
)

View file

@ -1,10 +1,10 @@
package org.gotson.komga.domain.persistence
import org.gotson.komga.domain.model.Book
import org.gotson.komga.domain.model.BookSearch
import org.gotson.komga.domain.model.SearchCondition
import org.gotson.komga.domain.model.SearchContext
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import java.math.BigDecimal
import java.net.URL
@ -27,10 +27,9 @@ interface BookRepository {
urls: Collection<URL>,
): Collection<Book>
fun findAll(bookSearch: BookSearch): Collection<Book>
fun findAll(
bookSearch: BookSearch,
searchCondition: SearchCondition.Book?,
searchContext: SearchContext,
pageable: Pageable,
): Page<Book>
@ -64,15 +63,8 @@ interface BookRepository {
fun findAllIdsBySeriesId(seriesId: String): Collection<String>
fun findAllIdsBySeriesIds(seriesIds: Collection<String>): Collection<String>
fun findAllIdsByLibraryId(libraryId: String): Collection<String>
fun findAllIds(
bookSearch: BookSearch,
sort: Sort,
): Collection<String>
fun existsById(bookId: String): Boolean
fun insert(book: Book)

View file

@ -26,7 +26,7 @@ interface MediaRepository {
fun delete(bookId: String)
fun deleteByBookIds(bookIds: Collection<String>)
fun delete(bookIds: Collection<String>)
fun count(): Long
}

View file

@ -1,7 +1,10 @@
package org.gotson.komga.domain.persistence
import org.gotson.komga.domain.model.SearchCondition
import org.gotson.komga.domain.model.SearchContext
import org.gotson.komga.domain.model.Series
import org.gotson.komga.domain.model.SeriesSearch
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import java.net.URL
interface SeriesRepository {
@ -23,7 +26,11 @@ interface SeriesRepository {
fun findAllByTitleContaining(title: String): Collection<Series>
fun findAll(search: SeriesSearch): Collection<Series>
fun findAll(
searchCondition: SearchCondition.Series?,
searchContext: SearchContext,
pageable: Pageable,
): Page<Series>
fun getLibraryId(seriesId: String): String?

View file

@ -1,22 +1,22 @@
package org.gotson.komga.domain.persistence
import org.gotson.komga.domain.model.BookSearch
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.SearchContext
import org.gotson.komga.domain.model.SyncPoint
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
interface SyncPointRepository {
fun create(
user: KomgaUser,
apiKeyId: String?,
search: BookSearch,
context: SearchContext,
): SyncPoint
fun addOnDeck(
syncPointId: String,
user: KomgaUser,
filterOnLibraryIds: Collection<String>?,
context: SearchContext,
filterOnLibraryIds: List<String>?,
)
fun findByIdOrNull(syncPointId: String): SyncPoint?

View file

@ -3,7 +3,6 @@ package org.gotson.komga.domain.service
import io.github.oshai.kotlinlogging.KotlinLogging
import org.gotson.komga.domain.model.Book
import org.gotson.komga.domain.model.BookAction
import org.gotson.komga.domain.model.BookSearch
import org.gotson.komga.domain.model.BookWithMedia
import org.gotson.komga.domain.model.DomainEvent
import org.gotson.komga.domain.model.HistoricalEvent
@ -17,6 +16,9 @@ import org.gotson.komga.domain.model.MediaProfile
import org.gotson.komga.domain.model.NoThumbnailFoundException
import org.gotson.komga.domain.model.R2Progression
import org.gotson.komga.domain.model.ReadProgress
import org.gotson.komga.domain.model.SearchCondition
import org.gotson.komga.domain.model.SearchContext
import org.gotson.komga.domain.model.SearchOperator
import org.gotson.komga.domain.model.ThumbnailBook
import org.gotson.komga.domain.model.TypedBytes
import org.gotson.komga.domain.persistence.BookMetadataRepository
@ -34,7 +36,7 @@ import org.gotson.komga.infrastructure.image.ImageType
import org.gotson.komga.language.toCurrentTimeZone
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.domain.Sort
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
import org.springframework.transaction.support.TransactionTemplate
import org.springframework.web.util.UriUtils
@ -280,7 +282,7 @@ class BookLifecycle(
return if (forBiggerResultOnly) {
thumbnailBookRepository.findAllBookIdsByThumbnailTypeAndDimensionSmallerThan(ThumbnailBook.Type.GENERATED, komgaSettingsProvider.thumbnailSize.maxEdge)
} else {
bookRepository.findAllIds(BookSearch(deleted = false), Sort.unsorted())
bookRepository.findAll(SearchCondition.Deleted(SearchOperator.IsFalse), SearchContext.empty(), Pageable.unpaged()).content.map { it.id }
}
}
@ -374,7 +376,7 @@ class BookLifecycle(
readProgressRepository.deleteByBookIds(bookIds)
readListRepository.removeBooksFromAll(bookIds)
mediaRepository.deleteByBookIds(bookIds)
mediaRepository.delete(bookIds)
thumbnailBookRepository.deleteByBookIds(bookIds)
bookMetadataRepository.delete(bookIds)

View file

@ -4,13 +4,14 @@ import io.github.oshai.kotlinlogging.KotlinLogging
import org.gotson.komga.application.tasks.TaskEmitter
import org.gotson.komga.domain.model.Book
import org.gotson.komga.domain.model.BookMetadataPatchCapability
import org.gotson.komga.domain.model.BookSearch
import org.gotson.komga.domain.model.DirectoryNotFoundException
import org.gotson.komga.domain.model.DomainEvent
import org.gotson.komga.domain.model.Library
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.SearchCondition
import org.gotson.komga.domain.model.SearchContext
import org.gotson.komga.domain.model.SearchOperator
import org.gotson.komga.domain.model.Series
import org.gotson.komga.domain.model.SeriesSearch
import org.gotson.komga.domain.model.Sidecar
import org.gotson.komga.domain.model.ThumbnailBook
import org.gotson.komga.domain.model.ThumbnailSeries
@ -31,6 +32,7 @@ import org.gotson.komga.infrastructure.hash.Hasher
import org.gotson.komga.language.notEquals
import org.gotson.komga.language.toIndexedMap
import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
import org.springframework.transaction.support.TransactionTemplate
import java.nio.file.Paths
@ -279,7 +281,7 @@ class LibraryContentLifecycle(
val bookSizes = newBooks.map { it.fileSize }
val deletedCandidates =
seriesRepository.findAll(SeriesSearch(deleted = true))
seriesRepository.findAll(SearchCondition.Deleted(SearchOperator.IsTrue), SearchContext.empty(), Pageable.unpaged()).content
.mapNotNull { deletedCandidate ->
val deletedBooks = bookRepository.findAllBySeriesId(deletedCandidate.id)
val deletedBooksSizes = deletedBooks.map { it.fileSize }
@ -419,10 +421,26 @@ class LibraryContentLifecycle(
fun emptyTrash(library: Library) {
logger.info { "Empty trash for library: $library" }
val seriesToDelete = seriesRepository.findAll(SeriesSearch(libraryIds = listOf(library.id), deleted = true))
val seriesToDelete =
seriesRepository.findAll(
SearchCondition.AllOfSeries(
SearchCondition.LibraryId(SearchOperator.Is(library.id)),
SearchCondition.Deleted(SearchOperator.IsTrue),
),
SearchContext.empty(),
Pageable.unpaged(),
).content
seriesLifecycle.deleteMany(seriesToDelete)
val booksToDelete = bookRepository.findAll(BookSearch(libraryIds = listOf(library.id), deleted = true))
val booksToDelete =
bookRepository.findAll(
SearchCondition.AllOfBook(
SearchCondition.LibraryId(SearchOperator.Is(library.id)),
SearchCondition.Deleted(SearchOperator.IsTrue),
),
SearchContext.empty(),
Pageable.unpaged(),
).content
bookLifecycle.deleteMany(booksToDelete)
booksToDelete.map { it.seriesId }.distinct().forEach { seriesId ->
seriesRepository.findByIdOrNull(seriesId)?.let { seriesLifecycle.sortBooks(it) }

View file

@ -4,6 +4,9 @@ import org.gotson.komga.domain.model.BookSearch
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.MediaProfile
import org.gotson.komga.domain.model.SearchCondition
import org.gotson.komga.domain.model.SearchContext
import org.gotson.komga.domain.model.SearchOperator
import org.gotson.komga.domain.model.SyncPoint
import org.gotson.komga.domain.persistence.SyncPointRepository
import org.springframework.data.domain.Page
@ -19,20 +22,32 @@ class SyncPointLifecycle(
apiKeyId: String?,
libraryIds: List<String>?,
): SyncPoint {
val authorizedLibraryIds = user.getAuthorizedLibraryIds(libraryIds)
val context = SearchContext(user)
val syncPoint =
syncPointRepository.create(
user,
syncPointRepository.
create(
apiKeyId,
BookSearch(
libraryIds = authorizedLibraryIds,
mediaStatus = setOf(Media.Status.READY),
mediaProfile = listOf(MediaProfile.EPUB),
deleted = false,
SearchCondition.AllOfBook(
buildList {
libraryIds?.let {
add(
SearchCondition.AnyOfBook(
it.map { libraryId -> SearchCondition.LibraryId(SearchOperator.Is(libraryId)) },
),
)
}
add(SearchCondition.MediaStatus(SearchOperator.Is(Media.Status.READY)))
add(SearchCondition.MediaProfile(SearchOperator.Is(MediaProfile.EPUB)))
add(SearchCondition.Deleted(SearchOperator.IsFalse))
},
),
),
context,
)
syncPointRepository.addOnDeck(syncPoint.id, user, authorizedLibraryIds)
syncPointRepository.addOnDeck(syncPoint.id, context, libraryIds)
return syncPoint
}

View file

@ -37,7 +37,7 @@ class Hasher {
}
@OptIn(ExperimentalUnsignedTypes::class)
private fun ByteArray.toHexString(): String =
fun ByteArray.toHexString(): String =
asUByteArray().joinToString("") {
it.toString(16).padStart(2, '0')
}

View file

@ -0,0 +1,182 @@
package org.gotson.komga.infrastructure.jooq
import io.github.oshai.kotlinlogging.KotlinLogging
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.MediaType
import org.gotson.komga.domain.model.ReadStatus
import org.gotson.komga.domain.model.SearchCondition
import org.gotson.komga.domain.model.SearchContext
import org.gotson.komga.domain.model.SearchOperator
import org.gotson.komga.infrastructure.datasource.SqliteUdfDataSource
import org.gotson.komga.infrastructure.jooq.RequiredJoin.ReadProgress
import org.gotson.komga.jooq.main.Tables
import org.jooq.Condition
import org.jooq.impl.DSL
private val logger = KotlinLogging.logger {}
/**
* Helper class to generate a Jooq Condition from a [SearchCondition.Book] and [SearchContext]
*/
class BookSearchHelper(
val context: SearchContext,
) : ContentRestrictionsSearchHelper() {
fun toCondition(searchCondition: SearchCondition.Book?): Pair<Condition, Set<RequiredJoin>> {
val base = toCondition()
val search = toConditionInternal(searchCondition)
return search.first.and(base.first) to (search.second + base.second)
}
fun toCondition(): Pair<Condition, Set<RequiredJoin>> {
val restrictions = toConditionInternal(context.restrictions)
val authorizedLibraries = toConditionInternal(context.libraryIds)
return restrictions.first.and(authorizedLibraries.first) to (restrictions.second + authorizedLibraries.second)
}
private fun toConditionInternal(libraryIds: Collection<String>?): Pair<Condition, Set<RequiredJoin>> {
if (libraryIds == null) return DSL.noCondition() to emptySet()
if (libraryIds.isEmpty()) return DSL.falseCondition() to emptySet()
return toConditionInternal(SearchCondition.AnyOfBook(libraryIds.map { SearchCondition.LibraryId(SearchOperator.Is(it)) }))
}
private fun toConditionInternal(searchCondition: SearchCondition.Book?): Pair<Condition, Set<RequiredJoin>> {
return when (searchCondition) {
is SearchCondition.AllOfBook ->
searchCondition.conditions.fold(DSL.noCondition() to emptySet()) { acc: Pair<Condition, Set<RequiredJoin>>, cond: SearchCondition.Book ->
val bookCondition = toConditionInternal(cond)
acc.first.and(bookCondition.first) to (acc.second + bookCondition.second)
}
is SearchCondition.AnyOfBook ->
searchCondition.conditions.fold(DSL.noCondition() to emptySet()) { acc: Pair<Condition, Set<RequiredJoin>>, cond: SearchCondition.Book ->
val bookCondition = toConditionInternal(cond)
acc.first.or(bookCondition.first) to (acc.second + bookCondition.second)
}
is SearchCondition.LibraryId -> searchCondition.operator.toCondition(Tables.BOOK.LIBRARY_ID) to emptySet()
is SearchCondition.SeriesId -> searchCondition.operator.toCondition(Tables.BOOK.SERIES_ID) to emptySet()
is SearchCondition.ReadListId ->
Tables.BOOK.ID.let { field ->
val inner = { readListId: String ->
DSL.select(Tables.READLIST_BOOK.BOOK_ID)
.from(Tables.READLIST_BOOK)
.where(Tables.READLIST_BOOK.READLIST_ID.eq(readListId))
}
when (searchCondition.operator) {
is SearchOperator.Is -> field.`in`(inner(searchCondition.operator.value))
is SearchOperator.IsNot -> field.notIn(inner(searchCondition.operator.value))
}
} to emptySet()
is SearchCondition.Title ->
searchCondition.operator.toCondition(Tables.BOOK_METADATA.TITLE) to
setOf(
RequiredJoin.BookMetadata,
)
is SearchCondition.Deleted ->
Tables.BOOK.DELETED_DATE.let {
when (searchCondition.operator) {
SearchOperator.IsFalse -> it.isNull
SearchOperator.IsTrue -> it.isNotNull
}
} to emptySet()
is SearchCondition.ReleaseDate ->
searchCondition.operator.toCondition(Tables.BOOK_METADATA.RELEASE_DATE) to
setOf(
RequiredJoin.BookMetadata,
)
is SearchCondition.NumberSort ->
searchCondition.operator.toCondition(Tables.BOOK_METADATA.NUMBER_SORT) to
setOf(
RequiredJoin.BookMetadata,
)
is SearchCondition.ReadStatus ->
if (context.userId == null) {
logger.warn { "SearchCondition.ReadStatus without userId in search context" }
DSL.falseCondition() to emptySet()
} else {
Tables.READ_PROGRESS.COMPLETED.let {
when (searchCondition.operator) {
is SearchOperator.Is -> {
when (searchCondition.operator.value) {
ReadStatus.UNREAD -> it.isNull
ReadStatus.READ -> it.isTrue
ReadStatus.IN_PROGRESS -> it.isFalse
}
}
is SearchOperator.IsNot ->
when (searchCondition.operator.value) {
ReadStatus.UNREAD -> it.isNotNull
ReadStatus.READ -> it.isNull.or(it.isFalse)
ReadStatus.IN_PROGRESS -> it.isTrue.or(it.isNull)
}
}
} to setOf(ReadProgress(context.userId))
}
is SearchCondition.MediaStatus ->
searchCondition.operator.toCondition(Tables.MEDIA.STATUS, Media.Status::name) to
setOf(
RequiredJoin.Media,
)
is SearchCondition.MediaProfile ->
Tables.MEDIA.MEDIA_TYPE.let { field ->
when (searchCondition.operator) {
is SearchOperator.Is -> field.`in`(MediaType.matchingMediaProfile(searchCondition.operator.value).map { it.type }.toSet())
is SearchOperator.IsNot -> field.notIn(MediaType.matchingMediaProfile(searchCondition.operator.value).map { it.type }.toSet())
}
} to setOf(RequiredJoin.Media)
is SearchCondition.Tag ->
Tables.BOOK.ID.let { field ->
val inner = { tag: String ->
DSL.select(Tables.BOOK_METADATA_TAG.BOOK_ID)
.from(Tables.BOOK_METADATA_TAG)
.where(Tables.BOOK_METADATA_TAG.TAG.collate(SqliteUdfDataSource.COLLATION_UNICODE_3).equalIgnoreCase(tag))
}
when (searchCondition.operator) {
is SearchOperator.Is -> field.`in`(inner(searchCondition.operator.value))
is SearchOperator.IsNot -> field.notIn(inner(searchCondition.operator.value))
}
} to emptySet()
is SearchCondition.Author ->
Tables.BOOK.ID.let { field ->
val inner = { name: String?, role: String? ->
DSL.select(Tables.BOOK_METADATA_AUTHOR.BOOK_ID)
.from(Tables.BOOK_METADATA_AUTHOR)
.where(DSL.noCondition())
.apply { if (name != null) and(Tables.BOOK_METADATA_AUTHOR.NAME.collate(SqliteUdfDataSource.COLLATION_UNICODE_3).equalIgnoreCase(name)) }
.apply { if (role != null) and(Tables.BOOK_METADATA_AUTHOR.ROLE.collate(SqliteUdfDataSource.COLLATION_UNICODE_3).equalIgnoreCase(role)) }
}
when (searchCondition.operator) {
is SearchOperator.Is -> {
if (searchCondition.operator.value.name == null && searchCondition.operator.value.role == null)
DSL.noCondition()
else
field.`in`(inner(searchCondition.operator.value.name, searchCondition.operator.value.role))
}
is SearchOperator.IsNot -> {
if (searchCondition.operator.value.name == null && searchCondition.operator.value.role == null)
DSL.noCondition()
else
field.notIn(inner(searchCondition.operator.value.name, searchCondition.operator.value.role))
}
} to emptySet()
}
is SearchCondition.OneShot -> searchCondition.operator.toCondition(Tables.BOOK.ONESHOT) to emptySet()
null -> DSL.noCondition() to emptySet()
}
}
}

View file

@ -0,0 +1,47 @@
package org.gotson.komga.infrastructure.jooq
import org.gotson.komga.domain.model.AllowExclude
import org.gotson.komga.domain.model.ContentRestrictions
import org.gotson.komga.jooq.main.Tables
import org.jooq.Condition
import org.jooq.impl.DSL
abstract class ContentRestrictionsSearchHelper {
protected fun toConditionInternal(restrictions: ContentRestrictions): Pair<Condition, Set<RequiredJoin>> {
val ageAllowed =
if (restrictions.ageRestriction?.restriction == AllowExclude.ALLOW_ONLY) {
Tables.SERIES_METADATA.AGE_RATING.isNotNull.and(Tables.SERIES_METADATA.AGE_RATING.lessOrEqual(restrictions.ageRestriction.age)) to setOf(RequiredJoin.SeriesMetadata)
} else {
DSL.noCondition() to emptySet()
}
val labelAllowed =
if (restrictions.labelsAllow.isNotEmpty())
Tables.SERIES_METADATA.SERIES_ID.`in`(
DSL.select(Tables.SERIES_METADATA_SHARING.SERIES_ID)
.from(Tables.SERIES_METADATA_SHARING)
.where(Tables.SERIES_METADATA_SHARING.LABEL.`in`(restrictions.labelsAllow)),
) to setOf(RequiredJoin.SeriesMetadata)
else
DSL.noCondition() to emptySet()
val ageDenied =
if (restrictions.ageRestriction?.restriction == AllowExclude.EXCLUDE)
Tables.SERIES_METADATA.AGE_RATING.isNull.or(Tables.SERIES_METADATA.AGE_RATING.lessThan(restrictions.ageRestriction.age)) to setOf(RequiredJoin.SeriesMetadata)
else
DSL.noCondition() to emptySet()
val labelDenied =
if (restrictions.labelsExclude.isNotEmpty())
Tables.SERIES_METADATA.SERIES_ID.notIn(
DSL.select(Tables.SERIES_METADATA_SHARING.SERIES_ID)
.from(Tables.SERIES_METADATA_SHARING)
.where(Tables.SERIES_METADATA_SHARING.LABEL.`in`(restrictions.labelsExclude)),
) to setOf(RequiredJoin.SeriesMetadata)
else
DSL.noCondition() to emptySet()
return ageAllowed.first.or(labelAllowed.first)
.and(ageDenied.first.and(labelDenied.first)) to (ageAllowed.second + labelAllowed.second + ageDenied.second + labelDenied.second)
}
}

View file

@ -0,0 +1,16 @@
package org.gotson.komga.infrastructure.jooq
/**
* An indication that some tables need to be joined for query conditions to work
*/
sealed class RequiredJoin {
data object BookMetadata : RequiredJoin()
data object Media : RequiredJoin()
data class ReadProgress(val userId: String) : RequiredJoin()
data object BookMetadataAggregation : RequiredJoin()
data object SeriesMetadata : RequiredJoin()
}

View file

@ -0,0 +1,77 @@
package org.gotson.komga.infrastructure.jooq
import org.gotson.komga.domain.model.SearchOperator
import org.jooq.Field
import java.time.LocalDate
import java.time.ZoneOffset
import java.time.temporal.ChronoUnit
fun SearchOperator.Equality<String>.toCondition(
field: Field<String>,
ignoreCase: Boolean = false,
) =
when (this) {
is SearchOperator.Is -> if (ignoreCase) field.equalIgnoreCase(this.value) else field.eq(this.value)
is SearchOperator.IsNot -> if (ignoreCase) field.notEqualIgnoreCase(this.value) else field.ne(this.value)
}
fun <T> SearchOperator.Equality<T>.toCondition(field: Field<T>) =
when (this) {
is SearchOperator.Is -> field.eq(this.value)
is SearchOperator.IsNot -> field.ne(this.value)
}
fun <T> SearchOperator.Equality<T>.toCondition(
field: Field<String>,
converter: (T) -> String,
) =
when (this) {
is SearchOperator.Is -> field.eq(converter(this.value))
is SearchOperator.IsNot -> field.ne(converter(this.value))
}
fun SearchOperator.StringOp.toCondition(field: Field<String>) =
when (this) {
is SearchOperator.BeginsWith -> field.startsWithIgnoreCase(value)
is SearchOperator.DoesNotBeginWith -> field.startsWithIgnoreCase(value).not()
is SearchOperator.EndsWith -> field.endsWithIgnoreCase(value)
is SearchOperator.DoesNotEndWith -> field.endsWithIgnoreCase(value).not()
is SearchOperator.Contains -> field.containsIgnoreCase(value)
is SearchOperator.DoesNotContain -> field.notContainsIgnoreCase(value)
is SearchOperator.Is<*> -> field.equalIgnoreCase(value as String)
is SearchOperator.IsNot<*> -> field.notEqualIgnoreCase(value as String)
}
fun SearchOperator.Date.toCondition(field: Field<LocalDate>) =
when (this) {
is SearchOperator.After -> field.gt(dateTime.withZoneSameInstant(ZoneOffset.UTC).toLocalDate())
is SearchOperator.Before -> field.lt(dateTime.withZoneSameInstant(ZoneOffset.UTC).toLocalDate())
is SearchOperator.IsInTheLast -> field.gt(LocalDate.now(ZoneOffset.UTC).minus(duration.toDays(), ChronoUnit.DAYS))
is SearchOperator.IsNotInTheLast -> field.lt(LocalDate.now(ZoneOffset.UTC).minus(duration.toDays(), ChronoUnit.DAYS))
SearchOperator.IsNull -> field.isNull
SearchOperator.IsNotNull -> field.isNotNull
}
fun SearchOperator.NumericNullable<Int>.toCondition(field: Field<Int>) =
when (this) {
is SearchOperator.Is<*> -> field.eq(value as Int)
is SearchOperator.IsNot<*> -> field.ne(value as Int).or(field.isNull)
is SearchOperator.GreaterThan -> field.gt(value)
is SearchOperator.LessThan -> field.lt(value)
is SearchOperator.IsNullT -> field.isNull
is SearchOperator.IsNotNullT -> field.isNotNull
}
fun SearchOperator.Numeric<Float>.toCondition(field: Field<Float>) =
when (this) {
is SearchOperator.Is<*> -> field.eq(value as Float)
is SearchOperator.IsNot<*> -> field.ne(value as Float)
is SearchOperator.GreaterThan -> field.gt(value)
is SearchOperator.LessThan -> field.lt(value)
}
fun SearchOperator.Boolean.toCondition(field: Field<Boolean>) =
when (this) {
SearchOperator.IsTrue -> field.isTrue
SearchOperator.IsFalse -> field.isFalse
}

View file

@ -0,0 +1,226 @@
package org.gotson.komga.infrastructure.jooq
import io.github.oshai.kotlinlogging.KotlinLogging
import org.gotson.komga.domain.model.ReadStatus
import org.gotson.komga.domain.model.SearchCondition
import org.gotson.komga.domain.model.SearchContext
import org.gotson.komga.domain.model.SearchOperator
import org.gotson.komga.domain.model.SeriesMetadata
import org.gotson.komga.infrastructure.datasource.SqliteUdfDataSource
import org.gotson.komga.jooq.main.Tables
import org.jooq.Condition
import org.jooq.impl.DSL
private val logger = KotlinLogging.logger {}
/**
* Helper class to generate a Jooq Condition from a [SearchCondition.Series] and [SearchContext]
*/
class SeriesSearchHelper(
val context: SearchContext,
) : ContentRestrictionsSearchHelper() {
fun toCondition(searchCondition: SearchCondition.Series?): Pair<Condition, Set<RequiredJoin>> {
val base = toCondition()
val search = toConditionInternal(searchCondition)
return search.first.and(base.first) to (search.second + base.second)
}
fun toCondition(): Pair<Condition, Set<RequiredJoin>> {
val restrictions = toConditionInternal(context.restrictions)
val authorizedLibraries = toConditionInternal(context.libraryIds)
return restrictions.first.and(authorizedLibraries.first) to (restrictions.second + authorizedLibraries.second)
}
private fun toConditionInternal(libraryIds: Collection<String>?): Pair<Condition, Set<RequiredJoin>> {
if (libraryIds == null) return DSL.noCondition() to emptySet()
if (libraryIds.isEmpty()) return DSL.falseCondition() to emptySet()
return toConditionInternal(SearchCondition.AnyOfSeries(libraryIds.map { SearchCondition.LibraryId(SearchOperator.Is(it)) }))
}
private fun toConditionInternal(searchCondition: SearchCondition.Series?): Pair<Condition, Set<RequiredJoin>> {
return when (searchCondition) {
is SearchCondition.AllOfSeries ->
searchCondition.conditions.fold(DSL.noCondition() to emptySet()) { acc: Pair<Condition, Set<RequiredJoin>>, cond: SearchCondition.Series ->
val seriesCondition = toConditionInternal(cond)
acc.first.and(seriesCondition.first) to (acc.second + seriesCondition.second)
}
is SearchCondition.AnyOfSeries ->
searchCondition.conditions.fold(DSL.noCondition() to emptySet()) { acc: Pair<Condition, Set<RequiredJoin>>, cond: SearchCondition.Series ->
val seriesCondition = toConditionInternal(cond)
acc.first.or(seriesCondition.first) to (acc.second + seriesCondition.second)
}
is SearchCondition.LibraryId -> searchCondition.operator.toCondition(Tables.SERIES.LIBRARY_ID) to emptySet()
is SearchCondition.Deleted ->
Tables.SERIES.DELETED_DATE.let {
when (searchCondition.operator) {
SearchOperator.IsFalse -> it.isNull
SearchOperator.IsTrue -> it.isNotNull
}
} to emptySet()
is SearchCondition.ReleaseDate ->
searchCondition.operator.toCondition(Tables.BOOK_METADATA_AGGREGATION.RELEASE_DATE) to setOf(RequiredJoin.BookMetadataAggregation)
is SearchCondition.ReadStatus ->
if (context.userId == null) {
logger.warn { "SearchCondition.ReadStatus without userId in search context" }
DSL.falseCondition() to emptySet()
} else {
Tables.READ_PROGRESS_SERIES.READ_COUNT.let { field ->
when (searchCondition.operator) {
is SearchOperator.Is -> {
when (searchCondition.operator.value) {
ReadStatus.UNREAD -> field.isNull
ReadStatus.READ -> field.eq(Tables.SERIES.BOOK_COUNT)
ReadStatus.IN_PROGRESS -> field.ne(Tables.SERIES.BOOK_COUNT)
}
}
is SearchOperator.IsNot ->
when (searchCondition.operator.value) {
ReadStatus.UNREAD -> field.isNotNull
ReadStatus.READ -> field.ne(Tables.SERIES.BOOK_COUNT).or(field.isNull)
ReadStatus.IN_PROGRESS -> field.eq(Tables.SERIES.BOOK_COUNT).or(field.isNull)
}
}
} to setOf(RequiredJoin.ReadProgress(context.userId))
}
is SearchCondition.SeriesStatus ->
searchCondition.operator.toCondition(Tables.SERIES_METADATA.STATUS, SeriesMetadata.Status::name) to
setOf(
RequiredJoin.SeriesMetadata,
)
is SearchCondition.Tag ->
Tables.SERIES.ID.let { field ->
val inner = { tag: String ->
DSL.select(Tables.SERIES_METADATA_TAG.SERIES_ID)
.from(Tables.SERIES_METADATA_TAG)
.where(Tables.SERIES_METADATA_TAG.TAG.collate(SqliteUdfDataSource.COLLATION_UNICODE_3).equalIgnoreCase(tag))
.union(
DSL.select(Tables.BOOK_METADATA_AGGREGATION_TAG.SERIES_ID)
.from(Tables.BOOK_METADATA_AGGREGATION_TAG)
.where(Tables.BOOK_METADATA_AGGREGATION_TAG.TAG.collate(SqliteUdfDataSource.COLLATION_UNICODE_3).equalIgnoreCase(tag)),
)
}
when (searchCondition.operator) {
is SearchOperator.Is -> field.`in`(inner(searchCondition.operator.value))
is SearchOperator.IsNot -> field.notIn(inner(searchCondition.operator.value))
}
} to emptySet()
is SearchCondition.Author ->
Tables.SERIES.ID.let { field ->
val inner = { name: String?, role: String? ->
DSL.select(Tables.BOOK_METADATA_AGGREGATION_AUTHOR.SERIES_ID)
.from(Tables.BOOK_METADATA_AGGREGATION_AUTHOR)
.where(DSL.noCondition())
.apply {
if (name != null)
and(
Tables.BOOK_METADATA_AGGREGATION_AUTHOR.NAME.collate(
SqliteUdfDataSource.COLLATION_UNICODE_3,
).equalIgnoreCase(name),
)
}
.apply {
if (role != null)
and(
Tables.BOOK_METADATA_AGGREGATION_AUTHOR.ROLE.collate(
SqliteUdfDataSource.COLLATION_UNICODE_3,
).equalIgnoreCase(role),
)
}
}
when (searchCondition.operator) {
is SearchOperator.Is -> {
if (searchCondition.operator.value.name == null && searchCondition.operator.value.role == null)
DSL.noCondition()
else
field.`in`(inner(searchCondition.operator.value.name, searchCondition.operator.value.role))
}
is SearchOperator.IsNot -> {
if (searchCondition.operator.value.name == null && searchCondition.operator.value.role == null)
DSL.noCondition()
else
field.notIn(inner(searchCondition.operator.value.name, searchCondition.operator.value.role))
}
} to emptySet()
}
is SearchCondition.OneShot -> searchCondition.operator.toCondition(Tables.SERIES.ONESHOT) to emptySet()
is SearchCondition.AgeRating -> searchCondition.operator.toCondition(Tables.SERIES_METADATA.AGE_RATING) to setOf(RequiredJoin.SeriesMetadata)
is SearchCondition.CollectionId ->
Tables.SERIES.ID.let { field ->
val inner = { collectionId: String ->
DSL.select(Tables.COLLECTION_SERIES.SERIES_ID)
.from(Tables.COLLECTION_SERIES)
.where(Tables.COLLECTION_SERIES.COLLECTION_ID.eq(collectionId))
}
when (searchCondition.operator) {
is SearchOperator.Is -> field.`in`(inner(searchCondition.operator.value))
is SearchOperator.IsNot -> field.notIn(inner(searchCondition.operator.value))
}
} to emptySet()
is SearchCondition.Complete ->
Tables.SERIES_METADATA.TOTAL_BOOK_COUNT.let { field ->
when (searchCondition.operator) {
SearchOperator.IsTrue -> field.isNotNull.and(field.eq(Tables.SERIES.BOOK_COUNT))
SearchOperator.IsFalse -> field.isNotNull.and(field.ne(Tables.SERIES.BOOK_COUNT))
} to setOf(RequiredJoin.SeriesMetadata)
}
is SearchCondition.Genre ->
Tables.SERIES.ID.let { field ->
val inner = { genre: String ->
DSL.select(Tables.SERIES_METADATA_GENRE.SERIES_ID)
.from(Tables.SERIES_METADATA_GENRE)
.where(Tables.SERIES_METADATA_GENRE.GENRE.collate(SqliteUdfDataSource.COLLATION_UNICODE_3).equalIgnoreCase(genre))
}
when (searchCondition.operator) {
is SearchOperator.Is -> field.`in`(inner(searchCondition.operator.value))
is SearchOperator.IsNot -> field.notIn(inner(searchCondition.operator.value))
}
} to emptySet()
is SearchCondition.Language -> searchCondition.operator.toCondition(Tables.SERIES_METADATA.LANGUAGE, true) to setOf(RequiredJoin.SeriesMetadata)
is SearchCondition.Publisher -> searchCondition.operator.toCondition(Tables.SERIES_METADATA.PUBLISHER, true) to setOf(RequiredJoin.SeriesMetadata)
is SearchCondition.SharingLabel ->
Tables.SERIES.ID.let { field ->
val inner = { label: String ->
DSL.select(Tables.SERIES_METADATA_SHARING.SERIES_ID)
.from(Tables.SERIES_METADATA_SHARING)
.where(Tables.SERIES_METADATA_SHARING.LABEL.collate(SqliteUdfDataSource.COLLATION_UNICODE_3).equalIgnoreCase(label))
}
when (searchCondition.operator) {
is SearchOperator.Is -> field.`in`(inner(searchCondition.operator.value))
is SearchOperator.IsNot -> field.notIn(inner(searchCondition.operator.value))
}
} to emptySet()
is SearchCondition.Title ->
searchCondition.operator.toCondition(Tables.SERIES_METADATA.TITLE) to
setOf(
RequiredJoin.SeriesMetadata,
)
is SearchCondition.TitleSort ->
searchCondition.operator.toCondition(Tables.SERIES_METADATA.TITLE_SORT) to
setOf(
RequiredJoin.SeriesMetadata,
)
null -> DSL.noCondition() to emptySet()
}
}
}

View file

@ -2,10 +2,8 @@ package org.gotson.komga.infrastructure.jooq
import com.fasterxml.jackson.databind.ObjectMapper
import org.gotson.komga.domain.model.AllowExclude
import org.gotson.komga.domain.model.BookSearch
import org.gotson.komga.domain.model.ContentRestrictions
import org.gotson.komga.domain.model.MediaExtension
import org.gotson.komga.domain.model.MediaType
import org.gotson.komga.infrastructure.datasource.SqliteUdfDataSource
import org.gotson.komga.jooq.main.Tables
import org.jooq.Condition
@ -70,22 +68,7 @@ fun DSLContext.insertTempStrings(
fun DSLContext.selectTempStrings() = this.select(Tables.TEMP_STRING_LIST.STRING).from(Tables.TEMP_STRING_LIST)
fun BookSearch.toCondition(): Condition {
var c: Condition = DSL.noCondition()
if (libraryIds != null) c = c.and(Tables.BOOK.LIBRARY_ID.`in`(libraryIds))
if (!seriesIds.isNullOrEmpty()) c = c.and(Tables.BOOK.SERIES_ID.`in`(seriesIds))
searchTerm?.let { c = c.and(Tables.BOOK_METADATA.TITLE.containsIgnoreCase(it)) }
if (!mediaStatus.isNullOrEmpty()) c = c.and(Tables.MEDIA.STATUS.`in`(mediaStatus))
if (!mediaProfile.isNullOrEmpty()) c = c.and(Tables.MEDIA.MEDIA_TYPE.`in`(mediaProfile.flatMap { profile -> MediaType.matchingMediaProfile(profile).map { it.type } }.toSet()))
if (deleted == true) c = c.and(Tables.BOOK.DELETED_DATE.isNotNull)
if (deleted == false) c = c.and(Tables.BOOK.DELETED_DATE.isNull)
if (releasedAfter != null) c = c.and(Tables.BOOK_METADATA.RELEASE_DATE.gt(releasedAfter))
return c
}
fun ContentRestrictions.toCondition(dsl: DSLContext): Condition {
fun ContentRestrictions.toCondition(): Condition {
val ageAllowed =
if (ageRestriction?.restriction == AllowExclude.ALLOW_ONLY) {
Tables.SERIES_METADATA.AGE_RATING.isNotNull.and(Tables.SERIES_METADATA.AGE_RATING.lessOrEqual(ageRestriction.age))
@ -96,7 +79,7 @@ fun ContentRestrictions.toCondition(dsl: DSLContext): Condition {
val labelAllowed =
if (labelsAllow.isNotEmpty())
Tables.SERIES_METADATA.SERIES_ID.`in`(
dsl.select(Tables.SERIES_METADATA_SHARING.SERIES_ID)
DSL.select(Tables.SERIES_METADATA_SHARING.SERIES_ID)
.from(Tables.SERIES_METADATA_SHARING)
.where(Tables.SERIES_METADATA_SHARING.LABEL.`in`(labelsAllow)),
)
@ -112,7 +95,7 @@ fun ContentRestrictions.toCondition(dsl: DSLContext): Condition {
val labelDenied =
if (labelsExclude.isNotEmpty())
Tables.SERIES_METADATA.SERIES_ID.notIn(
dsl.select(Tables.SERIES_METADATA_SHARING.SERIES_ID)
DSL.select(Tables.SERIES_METADATA_SHARING.SERIES_ID)
.from(Tables.SERIES_METADATA_SHARING)
.where(Tables.SERIES_METADATA_SHARING.LABEL.`in`(labelsExclude)),
)

View file

@ -45,7 +45,7 @@ class BookCommonDao(
.innerJoin(sd).on(s.ID.eq(sd.SERIES_ID))
.where(rs.IN_PROGRESS_COUNT.eq(0))
.and(rs.READ_COUNT.ne(s.BOOK_COUNT))
.and(restrictions.toCondition(dsl))
.and(restrictions.toCondition())
.apply { filterOnLibraryIds?.let<Collection<String>, Unit> { and(s.LIBRARY_ID.`in`(it)) } },
)

View file

@ -1,15 +1,18 @@
package org.gotson.komga.infrastructure.jooq.main
import org.gotson.komga.domain.model.Book
import org.gotson.komga.domain.model.BookSearch
import org.gotson.komga.domain.model.SearchCondition
import org.gotson.komga.domain.model.SearchContext
import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.infrastructure.jooq.BookSearchHelper
import org.gotson.komga.infrastructure.jooq.RequiredJoin
import org.gotson.komga.infrastructure.jooq.insertTempStrings
import org.gotson.komga.infrastructure.jooq.selectTempStrings
import org.gotson.komga.infrastructure.jooq.toCondition
import org.gotson.komga.infrastructure.jooq.toOrderBy
import org.gotson.komga.jooq.main.Tables
import org.gotson.komga.jooq.main.tables.records.BookRecord
import org.gotson.komga.language.toCurrentTimeZone
import org.jooq.Condition
import org.jooq.DSLContext
import org.jooq.impl.DSL
import org.springframework.beans.factory.annotation.Value
@ -33,7 +36,9 @@ class BookDao(
private val b = Tables.BOOK
private val m = Tables.MEDIA
private val d = Tables.BOOK_METADATA
private val sd = Tables.SERIES_METADATA
private val r = Tables.READ_PROGRESS
private val rlb = Tables.READLIST_BOOK
private val sorts =
mapOf(
@ -104,26 +109,35 @@ class BookDao(
.fetchInto(b)
.map { it.toDomain() }
override fun findAll(bookSearch: BookSearch): Collection<Book> =
dsl.select(*b.fields())
.from(b)
.leftJoin(d).on(b.ID.eq(d.BOOK_ID))
.leftJoin(m).on(b.ID.eq(m.BOOK_ID))
.where(bookSearch.toCondition())
.fetchInto(b)
.map { it.toDomain() }
override fun findAll(
bookSearch: BookSearch,
searchCondition: SearchCondition.Book?,
searchContext: SearchContext,
pageable: Pageable,
): Page<Book> {
val conditions = bookSearch.toCondition()
val bookCondition = BookSearchHelper(searchContext).toCondition(searchCondition)
return findAll(bookCondition.first, bookCondition.second, pageable)
}
private fun findAll(
conditions: Condition,
joins: Set<RequiredJoin>,
pageable: Pageable,
): PageImpl<Book> {
val count =
dsl.selectCount()
.from(b)
.leftJoin(d).on(b.ID.eq(d.BOOK_ID))
.leftJoin(m).on(b.ID.eq(m.BOOK_ID))
.apply {
joins.forEach { join ->
when (join) {
RequiredJoin.BookMetadata -> innerJoin(d).on(b.ID.eq(d.BOOK_ID))
RequiredJoin.SeriesMetadata -> innerJoin(sd).on(b.SERIES_ID.eq(sd.SERIES_ID))
RequiredJoin.Media -> innerJoin(m).on(b.ID.eq(m.BOOK_ID))
is RequiredJoin.ReadProgress -> leftJoin(r).on(b.ID.eq(r.BOOK_ID)).and(r.USER_ID.eq(join.userId))
// shouldn't be required for books
RequiredJoin.BookMetadataAggregation -> Unit
}
}
}
.where(conditions)
.fetchOne(0, Long::class.java) ?: 0
@ -132,8 +146,18 @@ class BookDao(
val items =
dsl.select(*b.fields())
.from(b)
.leftJoin(d).on(b.ID.eq(d.BOOK_ID))
.leftJoin(m).on(b.ID.eq(m.BOOK_ID))
.apply {
joins.forEach { join ->
when (join) {
RequiredJoin.BookMetadata -> innerJoin(d).on(b.ID.eq(d.BOOK_ID))
RequiredJoin.SeriesMetadata -> innerJoin(sd).on(b.SERIES_ID.eq(sd.SERIES_ID))
RequiredJoin.Media -> innerJoin(m).on(b.ID.eq(m.BOOK_ID))
is RequiredJoin.ReadProgress -> leftJoin(r).on(b.ID.eq(r.BOOK_ID)).and(r.USER_ID.eq(join.userId))
// shouldn't be required for books
RequiredJoin.BookMetadataAggregation -> Unit
}
}
}
.where(conditions)
.orderBy(orderBy)
.apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) }
@ -201,35 +225,12 @@ class BookDao(
.where(b.SERIES_ID.eq(seriesId))
.fetch(b.ID)
override fun findAllIdsBySeriesIds(seriesIds: Collection<String>): Collection<String> =
dsl.select(b.ID)
.from(b)
.where(b.SERIES_ID.`in`(seriesIds))
.fetch(0, String::class.java)
override fun findAllIdsByLibraryId(libraryId: String): Collection<String> =
dsl.select(b.ID)
.from(b)
.where(b.LIBRARY_ID.eq(libraryId))
.fetch(b.ID)
override fun findAllIds(
bookSearch: BookSearch,
sort: Sort,
): Collection<String> {
val conditions = bookSearch.toCondition()
val orderBy = sort.toOrderBy(sorts)
return dsl.select(b.ID)
.from(b)
.leftJoin(m).on(b.ID.eq(m.BOOK_ID))
.leftJoin(d).on(b.ID.eq(d.BOOK_ID))
.where(conditions)
.orderBy(orderBy)
.fetch(b.ID)
}
override fun existsById(bookId: String): Boolean =
dsl.fetchExists(b, b.ID.eq(bookId))

View file

@ -1,11 +1,12 @@
package org.gotson.komga.infrastructure.jooq.main
import org.gotson.komga.domain.model.BookSearchWithReadProgress
import org.gotson.komga.domain.model.BookSearch
import org.gotson.komga.domain.model.ContentRestrictions
import org.gotson.komga.domain.model.MediaType
import org.gotson.komga.domain.model.ReadList
import org.gotson.komga.domain.model.ReadStatus
import org.gotson.komga.domain.model.SearchContext
import org.gotson.komga.infrastructure.datasource.SqliteUdfDataSource
import org.gotson.komga.infrastructure.jooq.BookSearchHelper
import org.gotson.komga.infrastructure.jooq.RequiredJoin
import org.gotson.komga.infrastructure.jooq.insertTempStrings
import org.gotson.komga.infrastructure.jooq.noCase
import org.gotson.komga.infrastructure.jooq.selectTempStrings
@ -33,6 +34,7 @@ import org.jooq.Condition
import org.jooq.DSLContext
import org.jooq.Record
import org.jooq.ResultQuery
import org.jooq.SelectOnConditionStep
import org.jooq.impl.DSL
import org.jooq.impl.DSL.falseCondition
import org.jooq.impl.DSL.noCondition
@ -58,9 +60,7 @@ class BookDtoDao(
private val m = Tables.MEDIA
private val d = Tables.BOOK_METADATA
private val r = Tables.READ_PROGRESS
private val rs = Tables.READ_PROGRESS_SERIES
private val a = Tables.BOOK_METADATA_AUTHOR
private val s = Tables.SERIES
private val sd = Tables.SERIES_METADATA
private val rlb = Tables.READLIST_BOOK
private val bt = Tables.BOOK_METADATA_TAG
@ -90,37 +90,31 @@ class BookDtoDao(
"readList.number" to rlb.NUMBER,
)
override fun findAll(pageable: Pageable): Page<BookDto> =
findAll(BookSearch(), SearchContext.ofAnonymousUser(), pageable)
override fun findAll(
search: BookSearchWithReadProgress,
userId: String,
context: SearchContext,
pageable: Pageable,
restrictions: ContentRestrictions,
): Page<BookDto> {
val conditions = search.toCondition().and(restrictions.toCondition(dsl))
): Page<BookDto> = findAll(BookSearch(), context, pageable)
return findAll(conditions, userId, pageable, false, null, search.searchTerm)
}
override fun findAllByReadListId(
readListId: String,
userId: String,
filterOnLibraryIds: Collection<String>?,
search: BookSearchWithReadProgress,
override fun findAll(
search: BookSearch,
context: SearchContext,
pageable: Pageable,
restrictions: ContentRestrictions,
): Page<BookDto> {
val conditions = rlb.READLIST_ID.eq(readListId).and(search.toCondition()).and(restrictions.toCondition(dsl))
requireNotNull(context.userId) { "Missing userId in search context" }
return findAll(conditions, userId, pageable, true, filterOnLibraryIds, search.searchTerm)
val (conditions, joins) = BookSearchHelper(context).toCondition(search.condition)
return findAll(conditions, context.userId, pageable, search.fullTextSearch, joins)
}
private fun findAll(
conditions: Condition,
userId: String,
pageable: Pageable,
selectReadListNumber: Boolean = false,
filterOnLibraryIds: Collection<String>?,
searchTerm: String?,
joins: Set<RequiredJoin>,
): Page<BookDto> {
val bookIds = luceneHelper.searchEntitiesIds(searchTerm, LuceneEntity.Book)
@ -153,18 +147,28 @@ class BookDtoDao(
.leftJoin(d).on(b.ID.eq(d.BOOK_ID))
.leftJoin(r).on(b.ID.eq(r.BOOK_ID)).and(readProgressCondition(userId))
.leftJoin(sd).on(b.SERIES_ID.eq(sd.SERIES_ID))
.apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } }
.apply { if (selectReadListNumber) leftJoin(rlb).on(b.ID.eq(rlb.BOOK_ID)) }
.apply {
joins.forEach { join ->
when (join) {
// always joined
RequiredJoin.BookMetadata -> Unit
RequiredJoin.Media -> Unit
is RequiredJoin.ReadProgress -> Unit
// Series joins - not needed
RequiredJoin.BookMetadataAggregation -> Unit
RequiredJoin.SeriesMetadata -> Unit
}
}
}
.where(conditions)
.and(searchCondition)
.groupBy(b.ID),
)
val dtos =
selectBase(userId, selectReadListNumber)
selectBase(userId, joins, pageable.sort.any { it.property == "readList.number" })
.where(conditions)
.and(searchCondition)
.apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } }
.orderBy(orderBy)
.apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) }
.fetchAndMap()
@ -324,9 +328,9 @@ class BookDtoDao(
.apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } }
.fetchOne(rlb.NUMBER)
return selectBase(userId, true)
return selectBase(userId, joinOnReadList = true)
.where(rlb.READLIST_ID.eq(readList.id))
.apply { if (restrictions.isRestricted) and(restrictions.toCondition(dsl)) }
.apply { if (restrictions.isRestricted) and(restrictions.toCondition()) }
.apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } }
.orderBy(rlb.NUMBER.let { if (next) it.asc() else it.desc() })
.seek(numberSort)
@ -343,7 +347,7 @@ class BookDtoDao(
.leftJoin(d).on(b.ID.eq(d.BOOK_ID))
.apply { if (restrictions.isRestricted) leftJoin(sd).on(sd.SERIES_ID.eq(b.SERIES_ID)) }
.where(rlb.READLIST_ID.eq(readList.id))
.apply { if (restrictions.isRestricted) and(restrictions.toCondition(dsl)) }
.apply { if (restrictions.isRestricted) and(restrictions.toCondition()) }
.apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } }
.orderBy(d.RELEASE_DATE)
.fetch(b.ID)
@ -363,21 +367,40 @@ class BookDtoDao(
private fun selectBase(
userId: String,
selectReadListNumber: Boolean = false,
) =
dsl.select(
*b.fields(),
*m.fields(),
*d.fields(),
*r.fields(),
sd.TITLE,
).apply { if (selectReadListNumber) select(rlb.NUMBER) }
joins: Set<RequiredJoin> = emptySet(),
joinOnReadList: Boolean = false,
): SelectOnConditionStep<Record> {
val selectFields =
listOf(
*b.fields(),
*m.fields(),
*d.fields(),
*r.fields(),
sd.TITLE,
)
return dsl
.let { if (joinOnReadList) it.selectDistinct(selectFields) else it.select(selectFields) }
.from(b)
.leftJoin(m).on(b.ID.eq(m.BOOK_ID))
.leftJoin(d).on(b.ID.eq(d.BOOK_ID))
.leftJoin(r).on(b.ID.eq(r.BOOK_ID)).and(readProgressCondition(userId))
.leftJoin(sd).on(b.SERIES_ID.eq(sd.SERIES_ID))
.apply { if (selectReadListNumber) leftJoin(rlb).on(b.ID.eq(rlb.BOOK_ID)) }
.apply {
if (joinOnReadList) leftJoin(rlb).on(b.ID.eq(rlb.BOOK_ID))
joins.forEach { join ->
when (join) {
// always joined
RequiredJoin.BookMetadata -> Unit
RequiredJoin.Media -> Unit
is RequiredJoin.ReadProgress -> Unit
// Series joins - not needed
RequiredJoin.BookMetadataAggregation -> Unit
RequiredJoin.SeriesMetadata -> Unit
}
}
}
}
private fun ResultQuery<Record>.fetchAndMap(): MutableList<BookDto> {
val records = fetch()
@ -417,42 +440,6 @@ class BookDtoDao(
}
}
private fun BookSearchWithReadProgress.toCondition(): Condition {
var c: Condition = noCondition()
if (libraryIds != null) c = c.and(b.LIBRARY_ID.`in`(libraryIds))
if (!seriesIds.isNullOrEmpty()) c = c.and(b.SERIES_ID.`in`(seriesIds))
if (!mediaStatus.isNullOrEmpty()) c = c.and(m.STATUS.`in`(mediaStatus))
if (!mediaProfile.isNullOrEmpty()) c = c.and(m.MEDIA_TYPE.`in`(mediaProfile.flatMap { profile -> MediaType.matchingMediaProfile(profile).map { it.type } }.toSet()))
if (deleted == true) c = c.and(b.DELETED_DATE.isNotNull)
if (deleted == false) c = c.and(b.DELETED_DATE.isNull)
if (releasedAfter != null) c = c.and(d.RELEASE_DATE.gt(releasedAfter))
if (!tags.isNullOrEmpty()) c = c.and(b.ID.`in`(dsl.select(bt.BOOK_ID).from(bt).where(bt.TAG.collate(SqliteUdfDataSource.COLLATION_UNICODE_3).`in`(tags))))
if (readStatus != null) {
val cr =
readStatus.map {
when (it) {
ReadStatus.UNREAD -> r.COMPLETED.isNull
ReadStatus.READ -> r.COMPLETED.isTrue
ReadStatus.IN_PROGRESS -> r.COMPLETED.isFalse
}
}.reduce { acc, condition -> acc.or(condition) }
c = c.and(cr)
}
if (!authors.isNullOrEmpty()) {
var ca = noCondition()
authors.forEach {
ca = ca.or(b.ID.`in`(dsl.select(a.BOOK_ID).from(a).where(a.NAME.equalIgnoreCase(it.name).and(a.ROLE.equalIgnoreCase(it.role)))))
}
c = c.and(ca)
}
return c
}
private fun BookRecord.toDto(
media: MediaDto,
metadata: BookMetadataDto,

View file

@ -266,7 +266,7 @@ class MediaDao(
}
@Transactional
override fun deleteByBookIds(bookIds: Collection<String>) {
override fun delete(bookIds: Collection<String>) {
dsl.insertTempStrings(batchSize, bookIds)
dsl.deleteFrom(p).where(p.BOOK_ID.`in`(dsl.selectTempStrings())).execute()

View file

@ -56,7 +56,7 @@ class ReadListDao(
selectBase(restrictions.isRestricted)
.where(rl.ID.eq(readListId))
.apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } }
.apply { if (restrictions.isRestricted) and(restrictions.toCondition(dsl)) }
.apply { if (restrictions.isRestricted) and(restrictions.toCondition()) }
.fetchAndMap(filterOnLibraryIds, restrictions)
.firstOrNull()
@ -74,7 +74,7 @@ class ReadListDao(
searchCondition
.and(b.LIBRARY_ID.inOrNoCondition(belongsToLibraryIds))
.and(b.LIBRARY_ID.inOrNoCondition(filterOnLibraryIds))
.and(restrictions.toCondition(dsl))
.and(restrictions.toCondition())
val queryIds =
if (belongsToLibraryIds == null && filterOnLibraryIds == null && !restrictions.isRestricted)
@ -131,12 +131,12 @@ class ReadListDao(
.leftJoin(rlb).on(rl.ID.eq(rlb.READLIST_ID))
.apply { if (restrictions.isRestricted) leftJoin(b).on(rlb.BOOK_ID.eq(b.ID)).leftJoin(sd).on(sd.SERIES_ID.eq(b.SERIES_ID)) }
.where(rlb.BOOK_ID.eq(containsBookId))
.apply { if (restrictions.isRestricted) and(restrictions.toCondition(dsl)) }
.apply { if (restrictions.isRestricted) and(restrictions.toCondition()) }
return selectBase(restrictions.isRestricted)
.where(rl.ID.`in`(queryIds))
.apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } }
.apply { if (restrictions.isRestricted) and(restrictions.toCondition(dsl)) }
.apply { if (restrictions.isRestricted) and(restrictions.toCondition()) }
.fetchAndMap(filterOnLibraryIds, restrictions)
}
@ -178,7 +178,7 @@ class ReadListDao(
.apply { if (restrictions.isRestricted) leftJoin(sd).on(sd.SERIES_ID.eq(b.SERIES_ID)) }
.where(rlb.READLIST_ID.eq(rr.id))
.apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } }
.apply { if (restrictions.isRestricted) and(restrictions.toCondition(dsl)) }
.apply { if (restrictions.isRestricted) and(restrictions.toCondition()) }
.orderBy(rlb.NUMBER.asc())
.fetchInto(rlb)
.mapNotNull { it.number to it.bookId }

View file

@ -503,7 +503,7 @@ class ReferentialDao(
.orderBy(sd.PUBLISHER.collate(SqliteUdfDataSource.COLLATION_UNICODE_3))
.fetchSet(sd.PUBLISHER)
override fun findAllAgeRatings(filterOnLibraryIds: Collection<String>?): Set<Int> =
override fun findAllAgeRatings(filterOnLibraryIds: Collection<String>?): Set<Int?> =
dsl.selectDistinct(sd.AGE_RATING)
.from(sd)
.apply {
@ -518,7 +518,7 @@ class ReferentialDao(
override fun findAllAgeRatingsByLibrary(
libraryId: String,
filterOnLibraryIds: Collection<String>?,
): Set<Int> =
): Set<Int?> =
dsl.selectDistinct(sd.AGE_RATING)
.from(sd)
.leftJoin(s).on(sd.SERIES_ID.eq(s.ID))

View file

@ -53,7 +53,7 @@ class SeriesCollectionDao(
selectBase(restrictions.isRestricted)
.where(c.ID.eq(collectionId))
.apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } }
.apply { if (restrictions.isRestricted) and(restrictions.toCondition(dsl)) }
.apply { if (restrictions.isRestricted) and(restrictions.toCondition()) }
.fetchAndMap(filterOnLibraryIds, restrictions)
.firstOrNull()
@ -71,7 +71,7 @@ class SeriesCollectionDao(
searchCondition
.and(s.LIBRARY_ID.inOrNoCondition(belongsToLibraryIds))
.and(s.LIBRARY_ID.inOrNoCondition(filterOnLibraryIds))
.and(restrictions.toCondition(dsl))
.and(restrictions.toCondition())
val queryIds =
if (belongsToLibraryIds == null && filterOnLibraryIds == null && !restrictions.isRestricted)
@ -128,12 +128,12 @@ class SeriesCollectionDao(
.leftJoin(cs).on(c.ID.eq(cs.COLLECTION_ID))
.apply { if (restrictions.isRestricted) leftJoin(sd).on(cs.SERIES_ID.eq(sd.SERIES_ID)) }
.where(cs.SERIES_ID.eq(containsSeriesId))
.apply { if (restrictions.isRestricted) and(restrictions.toCondition(dsl)) }
.apply { if (restrictions.isRestricted) and(restrictions.toCondition()) }
return selectBase(restrictions.isRestricted)
.where(c.ID.`in`(queryIds))
.apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } }
.apply { if (restrictions.isRestricted) and(restrictions.toCondition(dsl)) }
.apply { if (restrictions.isRestricted) and(restrictions.toCondition()) }
.fetchAndMap(filterOnLibraryIds, restrictions)
}
@ -175,7 +175,7 @@ class SeriesCollectionDao(
.apply { if (restrictions.isRestricted) leftJoin(sd).on(cs.SERIES_ID.eq(sd.SERIES_ID)) }
.where(cs.COLLECTION_ID.eq(cr.id))
.apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } }
.apply { if (restrictions.isRestricted) and(restrictions.toCondition(dsl)) }
.apply { if (restrictions.isRestricted) and(restrictions.toCondition()) }
.orderBy(cs.NUMBER.asc())
.fetchInto(cs)
.mapNotNull { it.seriesId }

View file

@ -1,9 +1,11 @@
package org.gotson.komga.infrastructure.jooq.main
import org.gotson.komga.domain.model.SearchCondition
import org.gotson.komga.domain.model.SearchContext
import org.gotson.komga.domain.model.Series
import org.gotson.komga.domain.model.SeriesSearch
import org.gotson.komga.domain.persistence.SeriesRepository
import org.gotson.komga.infrastructure.datasource.SqliteUdfDataSource
import org.gotson.komga.infrastructure.jooq.RequiredJoin
import org.gotson.komga.infrastructure.jooq.SeriesSearchHelper
import org.gotson.komga.infrastructure.jooq.insertTempStrings
import org.gotson.komga.infrastructure.jooq.selectTempStrings
import org.gotson.komga.jooq.main.Tables
@ -13,6 +15,11 @@ import org.jooq.Condition
import org.jooq.DSLContext
import org.jooq.impl.DSL
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional
import java.net.URL
@ -26,8 +33,9 @@ class SeriesDao(
) : SeriesRepository {
private val s = Tables.SERIES
private val d = Tables.SERIES_METADATA
private val rs = Tables.READ_PROGRESS_SERIES
private val cs = Tables.COLLECTION_SERIES
private val l = Tables.LIBRARY
private val bma = Tables.BOOK_METADATA_AGGREGATION
override fun findAll(): Collection<Series> =
dsl.selectFrom(s)
@ -93,16 +101,52 @@ class SeriesDao(
.where(s.LIBRARY_ID.eq(libraryId))
.fetch(s.ID)
override fun findAll(search: SeriesSearch): Collection<Series> {
val conditions = search.toCondition()
override fun findAll(
searchCondition: SearchCondition.Series?,
searchContext: SearchContext,
pageable: Pageable,
): Page<Series> {
val (conditions, joins) = SeriesSearchHelper(searchContext).toCondition(searchCondition)
return findAll(conditions, joins, pageable)
}
return dsl.selectDistinct(*s.fields())
.from(s)
.leftJoin(cs).on(s.ID.eq(cs.SERIES_ID))
.leftJoin(d).on(s.ID.eq(d.SERIES_ID))
.where(conditions)
.fetchInto(s)
.map { it.toDomain() }
private fun findAll(
conditions: Condition,
joins: Set<RequiredJoin>,
pageable: Pageable,
): Page<Series> {
val query =
dsl.selectDistinct(*s.fields())
.from(s)
.apply {
joins.forEach { join ->
when (join) {
RequiredJoin.BookMetadataAggregation -> leftJoin(bma).on(s.ID.eq(bma.SERIES_ID))
RequiredJoin.SeriesMetadata -> innerJoin(d).on(s.ID.eq(d.SERIES_ID))
is RequiredJoin.ReadProgress -> leftJoin(rs).on(rs.SERIES_ID.eq(s.ID)).and(rs.USER_ID.eq(join.userId))
// Book joins - not needed
RequiredJoin.BookMetadata -> Unit
RequiredJoin.Media -> Unit
}
}
}
.where(conditions)
val count = dsl.fetchCount(query)
val items =
query
.apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) }
.fetchInto(s)
.map { it.toDomain() }
return PageImpl(
items,
if (pageable.isPaged)
PageRequest.of(pageable.pageNumber, pageable.pageSize, Sort.unsorted())
else
PageRequest.of(0, maxOf(count, 20), Sort.unsorted()),
count.toLong(),
)
}
override fun insert(series: Series) {
@ -157,28 +201,6 @@ class SeriesDao(
.groupBy(s.LIBRARY_ID)
.fetchMap(s.LIBRARY_ID, DSL.count(s.ID))
private fun SeriesSearch.toCondition(): Condition {
var c: Condition = DSL.trueCondition()
if (libraryIds != null) 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(d.PUBLISHER.collate(SqliteUdfDataSource.COLLATION_UNICODE_3).`in`(publishers))
if (deleted == true) c = c.and(s.DELETED_DATE.isNotNull)
if (deleted == false) c = c.and(s.DELETED_DATE.isNull)
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,17 +1,17 @@
package org.gotson.komga.infrastructure.jooq.main
import io.github.oshai.kotlinlogging.KotlinLogging
import org.gotson.komga.domain.model.ContentRestrictions
import org.gotson.komga.domain.model.ReadStatus
import org.gotson.komga.domain.model.SearchContext
import org.gotson.komga.domain.model.SearchField
import org.gotson.komga.domain.model.SeriesSearch
import org.gotson.komga.domain.model.SeriesSearchWithReadProgress
import org.gotson.komga.infrastructure.datasource.SqliteUdfDataSource
import org.gotson.komga.infrastructure.jooq.RequiredJoin
import org.gotson.komga.infrastructure.jooq.SeriesSearchHelper
import org.gotson.komga.infrastructure.jooq.inOrNoCondition
import org.gotson.komga.infrastructure.jooq.insertTempStrings
import org.gotson.komga.infrastructure.jooq.noCase
import org.gotson.komga.infrastructure.jooq.selectTempStrings
import org.gotson.komga.infrastructure.jooq.sortByValues
import org.gotson.komga.infrastructure.jooq.toCondition
import org.gotson.komga.infrastructure.jooq.toSortField
import org.gotson.komga.infrastructure.search.LuceneEntity
import org.gotson.komga.infrastructure.search.LuceneHelper
@ -95,52 +95,50 @@ class SeriesDtoDao(
"booksCount" to s.BOOK_COUNT,
)
override fun findAll(pageable: Pageable): Page<SeriesDto> =
findAll(SeriesSearch(), SearchContext.ofAnonymousUser(), pageable)
override fun findAll(
search: SeriesSearchWithReadProgress,
userId: String,
context: SearchContext,
pageable: Pageable,
restrictions: ContentRestrictions,
): Page<SeriesDto> {
val conditions = search.toCondition().and(restrictions.toCondition(dsl))
): Page<SeriesDto> = findAll(SeriesSearch(), context, pageable)
return findAll(conditions, userId, pageable, search.toJoinConditions(), search.searchTerm)
}
override fun findAllByCollectionId(
collectionId: String,
search: SeriesSearchWithReadProgress,
userId: String,
override fun findAll(
search: SeriesSearch,
context: SearchContext,
pageable: Pageable,
restrictions: ContentRestrictions,
): Page<SeriesDto> {
val conditions = search.toCondition().and(restrictions.toCondition(dsl)).and(cs.COLLECTION_ID.eq(collectionId))
val joinConditions = search.toJoinConditions().copy(selectCollectionNumber = true, collection = true)
requireNotNull(context.userId) { "Missing userId in search context" }
return findAll(conditions, userId, pageable, joinConditions, search.searchTerm)
val (conditions, joins) = SeriesSearchHelper(context).toCondition(search.condition)
val conditionsRefined = conditions.and(search.regexSearch?.let { it.second.toColumn().likeRegex(it.first) } ?: DSL.noCondition())
return findAll(conditionsRefined, context.userId, pageable, joins, search.fullTextSearch)
}
override fun findAllRecentlyUpdated(
search: SeriesSearchWithReadProgress,
userId: String,
restrictions: ContentRestrictions,
search: SeriesSearch,
context: SearchContext,
pageable: Pageable,
): Page<SeriesDto> {
val conditions =
search.toCondition()
.and(restrictions.toCondition(dsl))
.and(s.CREATED_DATE.notEqual(s.LAST_MODIFIED_DATE))
requireNotNull(context.userId) { "Missing userId in search context" }
return findAll(conditions, userId, pageable, search.toJoinConditions(), search.searchTerm)
val (conditions, joins) = SeriesSearchHelper(context).toCondition(search.condition)
val conditionsRefined = conditions.and(s.CREATED_DATE.notEqual(s.LAST_MODIFIED_DATE))
return findAll(conditionsRefined, context.userId, pageable, joins, search.fullTextSearch)
}
override fun countByFirstCharacter(
search: SeriesSearchWithReadProgress,
userId: String,
restrictions: ContentRestrictions,
search: SeriesSearch,
context: SearchContext,
): List<GroupCountDto> {
val conditions = search.toCondition().and(restrictions.toCondition(dsl))
val joinConditions = search.toJoinConditions()
val seriesIds = luceneHelper.searchEntitiesIds(search.searchTerm, LuceneEntity.Series)
requireNotNull(context.userId) { "Missing userId in search context" }
val (conditions, joins) = SeriesSearchHelper(context).toCondition(search.condition)
val conditionsRefined = conditions.and(search.regexSearch?.let { it.second.toColumn().likeRegex(it.first) } ?: DSL.noCondition())
val seriesIds = luceneHelper.searchEntitiesIds(search.fullTextSearch, LuceneEntity.Series)
val searchCondition = s.ID.inOrNoCondition(seriesIds)
val firstChar = lower(substring(d.TITLE_SORT, 1, 1))
@ -148,17 +146,21 @@ class SeriesDtoDao(
.from(s)
.leftJoin(d).on(s.ID.eq(d.SERIES_ID))
.leftJoin(bma).on(s.ID.eq(bma.SERIES_ID))
.leftJoin(rs).on(s.ID.eq(rs.SERIES_ID)).and(readProgressConditionSeries(userId))
.apply { if (joinConditions.genre) leftJoin(g).on(s.ID.eq(g.SERIES_ID)) }
.leftJoin(rs).on(s.ID.eq(rs.SERIES_ID)).and(readProgressConditionSeries(context.userId))
.apply {
if (joinConditions.tag)
leftJoin(st).on(s.ID.eq(st.SERIES_ID))
.leftJoin(bmat).on(s.ID.eq(bmat.SERIES_ID))
joins.forEach { join ->
when (join) {
// always joined
is RequiredJoin.ReadProgress -> Unit
RequiredJoin.SeriesMetadata -> Unit
// Book joins - not needed
RequiredJoin.Media -> Unit
RequiredJoin.BookMetadata -> Unit
RequiredJoin.BookMetadataAggregation -> Unit
}
}
}
.apply { if (joinConditions.collection) leftJoin(cs).on(s.ID.eq(cs.SERIES_ID)) }
.apply { if (joinConditions.aggregationAuthor) leftJoin(bmaa).on(s.ID.eq(bmaa.SERIES_ID)) }
.apply { if (joinConditions.sharingLabel) leftJoin(sl).on(s.ID.eq(sl.SERIES_ID)) }
.where(conditions)
.where(conditionsRefined)
.and(searchCondition)
.groupBy(firstChar)
.map {
@ -178,30 +180,36 @@ class SeriesDtoDao(
private fun selectBase(
userId: String,
joinConditions: JoinConditions = JoinConditions(),
joins: Set<RequiredJoin> = emptySet(),
joinOnCollection: Boolean = false,
): SelectOnConditionStep<Record> =
dsl.selectDistinct(*groupFields)
.apply { if (joinConditions.selectCollectionNumber) select(cs.NUMBER) }
dsl
.let { if (joinOnCollection) it.selectDistinct(*groupFields) else it.select(*groupFields) }
.from(s)
.leftJoin(d).on(s.ID.eq(d.SERIES_ID))
.leftJoin(bma).on(s.ID.eq(bma.SERIES_ID))
.leftJoin(rs).on(s.ID.eq(rs.SERIES_ID)).and(readProgressConditionSeries(userId))
.apply { if (joinConditions.genre) leftJoin(g).on(s.ID.eq(g.SERIES_ID)) }
.apply {
if (joinConditions.tag)
leftJoin(st).on(s.ID.eq(st.SERIES_ID))
.leftJoin(bmat).on(s.ID.eq(bmat.SERIES_ID))
if (joinOnCollection)leftJoin(cs).on(s.ID.eq(cs.SERIES_ID))
joins.forEach { join ->
when (join) {
// always joined
is RequiredJoin.ReadProgress -> Unit
RequiredJoin.SeriesMetadata -> Unit
// Book joins - not needed
RequiredJoin.BookMetadata -> Unit
RequiredJoin.BookMetadataAggregation -> Unit
RequiredJoin.Media -> Unit
}
}
}
.apply { if (joinConditions.collection) leftJoin(cs).on(s.ID.eq(cs.SERIES_ID)) }
.apply { if (joinConditions.aggregationAuthor) leftJoin(bmaa).on(s.ID.eq(bmaa.SERIES_ID)) }
.apply { if (joinConditions.sharingLabel) leftJoin(sl).on(s.ID.eq(sl.SERIES_ID)) }
private fun findAll(
conditions: Condition,
userId: String,
pageable: Pageable,
joinConditions: JoinConditions = JoinConditions(),
searchTerm: String?,
joins: Set<RequiredJoin> = emptySet(),
searchTerm: String? = null,
): Page<SeriesDto> {
val seriesIds = luceneHelper.searchEntitiesIds(searchTerm, LuceneEntity.Series)
val searchCondition = s.ID.inOrNoCondition(seriesIds)
@ -212,15 +220,19 @@ class SeriesDtoDao(
.leftJoin(d).on(s.ID.eq(d.SERIES_ID))
.leftJoin(bma).on(s.ID.eq(bma.SERIES_ID))
.leftJoin(rs).on(s.ID.eq(rs.SERIES_ID)).and(readProgressConditionSeries(userId))
.apply { if (joinConditions.genre) leftJoin(g).on(s.ID.eq(g.SERIES_ID)) }
.apply {
if (joinConditions.tag)
leftJoin(st).on(s.ID.eq(st.SERIES_ID))
.leftJoin(bmat).on(s.ID.eq(bmat.SERIES_ID))
joins.forEach { join ->
when (join) {
// always joined
is RequiredJoin.ReadProgress -> Unit
RequiredJoin.SeriesMetadata -> Unit
// Book joins - not needed
RequiredJoin.BookMetadata -> Unit
RequiredJoin.BookMetadataAggregation -> Unit
RequiredJoin.Media -> Unit
}
}
}
.apply { if (joinConditions.collection) leftJoin(cs).on(s.ID.eq(cs.SERIES_ID)) }
.apply { if (joinConditions.aggregationAuthor) leftJoin(bmaa).on(s.ID.eq(bmaa.SERIES_ID)) }
.apply { if (joinConditions.sharingLabel) leftJoin(sl).on(s.ID.eq(sl.SERIES_ID)) }
.where(conditions)
.and(searchCondition)
.fetchOne(countDistinct(s.ID)) ?: 0
@ -234,7 +246,7 @@ class SeriesDtoDao(
}
val dtos =
selectBase(userId, joinConditions)
selectBase(userId, joins, pageable.sort.any { it.property == "collection.number" })
.where(conditions)
.and(searchCondition)
.orderBy(orderBy)
@ -325,77 +337,12 @@ class SeriesDtoDao(
}
}
private fun SeriesSearchWithReadProgress.toCondition(): Condition {
var c = DSL.noCondition()
if (libraryIds != null) c = c.and(s.LIBRARY_ID.`in`(libraryIds))
if (!collectionIds.isNullOrEmpty()) c = c.and(cs.COLLECTION_ID.`in`(collectionIds))
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(d.PUBLISHER.collate(SqliteUdfDataSource.COLLATION_UNICODE_3).`in`(publishers))
if (deleted == true) c = c.and(s.DELETED_DATE.isNotNull)
if (deleted == false) c = c.and(s.DELETED_DATE.isNull)
if (complete == false) c = c.and(d.TOTAL_BOOK_COUNT.isNotNull.and(d.TOTAL_BOOK_COUNT.ne(s.BOOK_COUNT)))
if (complete == true) c = c.and(d.TOTAL_BOOK_COUNT.isNotNull.and(d.TOTAL_BOOK_COUNT.eq(s.BOOK_COUNT)))
if (oneshot != null) c = c.and(s.ONESHOT.eq(oneshot))
if (!languages.isNullOrEmpty()) c = c.and(d.LANGUAGE.collate(SqliteUdfDataSource.COLLATION_UNICODE_3).`in`(languages))
if (!genres.isNullOrEmpty()) c = c.and(g.GENRE.collate(SqliteUdfDataSource.COLLATION_UNICODE_3).`in`(genres))
if (!tags.isNullOrEmpty()) c = c.and(st.TAG.collate(SqliteUdfDataSource.COLLATION_UNICODE_3).`in`(tags).or(bmat.TAG.collate(SqliteUdfDataSource.COLLATION_UNICODE_3).`in`(tags)))
if (!ageRatings.isNullOrEmpty()) {
val c1 = if (ageRatings.contains(null)) d.AGE_RATING.isNull else DSL.noCondition()
val c2 = if (ageRatings.filterNotNull().isNotEmpty()) d.AGE_RATING.`in`(ageRatings.filterNotNull()) else DSL.noCondition()
c = c.and(c1.or(c2))
}
// cast to String is necessary for SQLite, else the years in the IN block are coerced to Int, even though YEAR for SQLite uses strftime (string)
if (!releaseYears.isNullOrEmpty()) c = c.and(DSL.year(bma.RELEASE_DATE).cast(String::class.java).`in`(releaseYears))
if (!authors.isNullOrEmpty()) {
var ca = DSL.noCondition()
authors.forEach {
ca = ca.or(bmaa.NAME.equalIgnoreCase(it.name).and(bmaa.ROLE.equalIgnoreCase(it.role)))
}
c = c.and(ca)
}
if (!sharingLabels.isNullOrEmpty()) c = c.and(sl.LABEL.collate(SqliteUdfDataSource.COLLATION_UNICODE_3).`in`(sharingLabels))
if (!readStatus.isNullOrEmpty()) {
val cr =
readStatus.map {
when (it) {
ReadStatus.UNREAD -> rs.READ_COUNT.isNull
ReadStatus.READ -> rs.READ_COUNT.eq(s.BOOK_COUNT)
ReadStatus.IN_PROGRESS -> rs.READ_COUNT.ne(s.BOOK_COUNT)
}
}.reduce { acc, condition -> acc.or(condition) }
c = c.and(cr)
}
return c
}
private fun SeriesSearch.SearchField.toColumn() =
private fun SearchField.toColumn() =
when (this) {
SeriesSearch.SearchField.NAME -> s.NAME
SeriesSearch.SearchField.TITLE -> d.TITLE
SeriesSearch.SearchField.TITLE_SORT -> d.TITLE_SORT
SearchField.TITLE -> d.TITLE
SearchField.TITLE_SORT -> d.TITLE_SORT
}
private fun SeriesSearchWithReadProgress.toJoinConditions() =
JoinConditions(
genre = !genres.isNullOrEmpty(),
tag = !tags.isNullOrEmpty(),
collection = !collectionIds.isNullOrEmpty(),
aggregationAuthor = !authors.isNullOrEmpty(),
sharingLabel = !sharingLabels.isNullOrEmpty(),
)
private data class JoinConditions(
val selectCollectionNumber: Boolean = false,
val genre: Boolean = false,
val tag: Boolean = false,
val collection: Boolean = false,
val aggregationAuthor: Boolean = false,
val sharingLabel: Boolean = false,
)
private fun SeriesRecord.toDto(
booksCount: Int,
booksReadCount: Int,

View file

@ -2,11 +2,12 @@ package org.gotson.komga.infrastructure.jooq.main
import com.github.f4b6a3.tsid.TsidCreator
import org.gotson.komga.domain.model.BookSearch
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.SearchContext
import org.gotson.komga.domain.model.SyncPoint
import org.gotson.komga.domain.model.SyncPoint.ReadList.Companion.ON_DECK_ID
import org.gotson.komga.domain.persistence.SyncPointRepository
import org.gotson.komga.infrastructure.jooq.toCondition
import org.gotson.komga.infrastructure.jooq.BookSearchHelper
import org.gotson.komga.infrastructure.jooq.RequiredJoin
import org.gotson.komga.jooq.main.Tables
import org.gotson.komga.language.toZonedDateTime
import org.jooq.DSLContext
@ -44,11 +45,13 @@ class SyncPointDao(
@Transactional
override fun create(
user: KomgaUser,
apiKeyId: String?,
search: BookSearch,
context: SearchContext,
): SyncPoint {
val conditions = search.toCondition().and(user.restrictions.toCondition(dsl))
requireNotNull(context.userId) { "userId is required to create a SyncPoint" }
val (condition, joins) = BookSearchHelper(context).toCondition(search.condition)
val syncPointId = TsidCreator.getTsid256().toString()
val createdAt = LocalDateTime.now(ZoneId.of("Z"))
@ -61,7 +64,7 @@ class SyncPointDao(
sp.CREATED_DATE,
).values(
syncPointId,
user.id,
context.userId,
apiKeyId,
createdAt,
).execute()
@ -91,12 +94,24 @@ class SyncPointDao(
r.LAST_MODIFIED_DATE,
bt.ID,
).from(b)
.apply {
joins.forEach {
when (it) {
// we don't have to handle those since we already join on those tables anyway, the 'when' is here for future proofing
RequiredJoin.BookMetadata -> Unit
RequiredJoin.SeriesMetadata -> Unit
RequiredJoin.Media -> Unit
is RequiredJoin.ReadProgress -> Unit
RequiredJoin.BookMetadataAggregation -> Unit
}
}
}
.join(m).on(b.ID.eq(m.BOOK_ID))
.join(d).on(b.ID.eq(d.BOOK_ID))
.join(sd).on(b.SERIES_ID.eq(sd.SERIES_ID))
.leftJoin(r).on(b.ID.eq(r.BOOK_ID)).and(r.USER_ID.eq(user.id))
.leftJoin(r).on(b.ID.eq(r.BOOK_ID)).and(r.USER_ID.eq(context.userId))
.leftJoin(bt).on(b.ID.eq(bt.BOOK_ID)).and(bt.SELECTED.isTrue)
.where(conditions),
.where(condition),
).execute()
return findByIdOrNull(syncPointId)!!
@ -105,13 +120,15 @@ class SyncPointDao(
@Transactional
override fun addOnDeck(
syncPointId: String,
user: KomgaUser,
filterOnLibraryIds: Collection<String>?,
context: SearchContext,
filterOnLibraryIds: List<String>?,
) {
requireNotNull(context.userId) { "Missing userId in search context" }
val createdAt = LocalDateTime.now(ZoneId.of("Z"))
val onDeckFields: Array<Field<*>> = arrayOf(DSL.`val`(syncPointId), DSL.`val`(ON_DECK_ID), b.ID)
val (query, _, queryMostRecentDate) = bookCommonDao.getBooksOnDeckQuery(user.id, user.restrictions, filterOnLibraryIds, onDeckFields)
val (query, _, queryMostRecentDate) = bookCommonDao.getBooksOnDeckQuery(context.userId, context.restrictions, filterOnLibraryIds, onDeckFields)
val count =
dsl.insertInto(sprlb)

View file

@ -3,11 +3,9 @@ package org.gotson.komga.infrastructure.search
import io.github.oshai.kotlinlogging.KotlinLogging
import org.apache.lucene.document.Document
import org.apache.lucene.index.Term
import org.gotson.komga.domain.model.BookSearchWithReadProgress
import org.gotson.komga.domain.model.DomainEvent
import org.gotson.komga.domain.model.ReadList
import org.gotson.komga.domain.model.SeriesCollection
import org.gotson.komga.domain.model.SeriesSearchWithReadProgress
import org.gotson.komga.domain.persistence.ReadListRepository
import org.gotson.komga.domain.persistence.SeriesCollectionRepository
import org.gotson.komga.interfaces.api.persistence.BookDtoRepository
@ -45,8 +43,8 @@ class SearchIndexLifecycle(
targetEntities.forEach {
when (it) {
LuceneEntity.Book -> rebuildIndex(it, { p: Pageable -> bookDtoRepository.findAll(BookSearchWithReadProgress(), "unused", p) }, { e: BookDto -> e.bookToDocument() })
LuceneEntity.Series -> rebuildIndex(it, { p: Pageable -> seriesDtoRepository.findAll(SeriesSearchWithReadProgress(), "unused", p) }, { e: SeriesDto -> e.toDocument() })
LuceneEntity.Book -> rebuildIndex(it, { p: Pageable -> bookDtoRepository.findAll(p) }, { e: BookDto -> e.bookToDocument() })
LuceneEntity.Series -> rebuildIndex(it, { p: Pageable -> seriesDtoRepository.findAll(p) }, { e: SeriesDto -> e.toDocument() })
LuceneEntity.Collection -> rebuildIndex(it, { p: Pageable -> collectionRepository.findAll(pageable = p) }, { e: SeriesCollection -> e.toDocument() })
LuceneEntity.ReadList -> rebuildIndex(it, { p: Pageable -> readListRepository.findAll(pageable = p) }, { e: ReadList -> e.toDocument() })
}

View file

@ -2,6 +2,7 @@ package org.gotson.komga.infrastructure.web
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.VALUE_PARAMETER)
@Deprecated("was used only for search_regex which is deprecated")
annotation class DelimitedPair(
val parameterName: String,
)

View file

@ -6,15 +6,18 @@ import io.swagger.v3.oas.annotations.media.Content
import io.swagger.v3.oas.annotations.media.Schema
import io.swagger.v3.oas.annotations.responses.ApiResponse
import org.apache.commons.io.FilenameUtils
import org.gotson.komga.domain.model.BookSearchWithReadProgress
import org.gotson.komga.domain.model.BookSearch
import org.gotson.komga.domain.model.Library
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.MediaProfile
import org.gotson.komga.domain.model.ROLE_PAGE_STREAMING
import org.gotson.komga.domain.model.ReadList
import org.gotson.komga.domain.model.ReadStatus
import org.gotson.komga.domain.model.SearchCondition
import org.gotson.komga.domain.model.SearchContext
import org.gotson.komga.domain.model.SearchOperator
import org.gotson.komga.domain.model.SeriesCollection
import org.gotson.komga.domain.model.SeriesSearchWithReadProgress
import org.gotson.komga.domain.model.SeriesSearch
import org.gotson.komga.domain.model.ThumbnailBook
import org.gotson.komga.domain.persistence.LibraryRepository
import org.gotson.komga.domain.persistence.MediaRepository
@ -281,19 +284,19 @@ class OpdsController(
val pageable = PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.desc("readProgress.readDate")))
val bookSearch =
BookSearchWithReadProgress(
libraryIds = principal.user.getAuthorizedLibraryIds(null),
readStatus = setOf(ReadStatus.IN_PROGRESS),
mediaStatus = setOf(Media.Status.READY),
deleted = false,
BookSearch(
SearchCondition.AllOfBook(
SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.IN_PROGRESS)),
SearchCondition.MediaStatus(SearchOperator.Is(Media.Status.READY)),
SearchCondition.Deleted(SearchOperator.IsFalse),
),
)
val bookPage =
bookDtoRepository.findAll(
bookSearch,
principal.user.id,
SearchContext(principal.user),
pageable,
principal.user.restrictions,
)
val builder = uriBuilder(ROUTE_ON_DECK)
@ -329,14 +332,17 @@ class OpdsController(
val pageable = PageRequest.of(page.pageNumber, page.pageSize, sort)
val seriesSearch =
SeriesSearchWithReadProgress(
libraryIds = principal.user.getAuthorizedLibraryIds(null),
searchTerm = searchTerm,
publishers = publishers,
deleted = false,
SeriesSearch(
SearchCondition.AllOfSeries(
buildList {
searchTerm?.let { add(SearchCondition.Title(SearchOperator.Contains(it))) }
publishers?.let { add(SearchCondition.AnyOfSeries(it.map { publisher -> SearchCondition.Publisher(SearchOperator.Is(publisher)) })) }
add(SearchCondition.Deleted(SearchOperator.IsFalse))
},
),
)
val seriesPage = seriesDtoRepository.findAll(seriesSearch, principal.user.id, pageable, principal.user.restrictions)
val seriesPage = seriesDtoRepository.findAll(seriesSearch, SearchContext(principal.user), pageable)
val builder =
uriBuilder(ROUTE_SERIES_ALL)
@ -366,13 +372,9 @@ class OpdsController(
): OpdsFeed {
val pageable = PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.desc("lastModified")))
val seriesSearch =
SeriesSearchWithReadProgress(
libraryIds = principal.user.getAuthorizedLibraryIds(null),
deleted = false,
)
val seriesSearch = SeriesSearch(SearchCondition.Deleted(SearchOperator.IsFalse))
val seriesPage = seriesDtoRepository.findAll(seriesSearch, principal.user.id, pageable, principal.user.restrictions)
val seriesPage = seriesDtoRepository.findAll(seriesSearch, SearchContext(principal.user), pageable)
val uriBuilder = uriBuilder(ROUTE_SERIES_LATEST)
@ -398,14 +400,15 @@ class OpdsController(
@Parameter(hidden = true) page: Pageable,
): OpdsFeed {
val bookSearch =
BookSearchWithReadProgress(
libraryIds = principal.user.getAuthorizedLibraryIds(null),
mediaStatus = setOf(Media.Status.READY),
deleted = false,
BookSearch(
SearchCondition.AllOfBook(
SearchCondition.MediaStatus(SearchOperator.Is(Media.Status.READY)),
SearchCondition.Deleted(SearchOperator.IsFalse),
),
)
val pageable = PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.desc("createdDate")))
val bookPage = bookDtoRepository.findAll(bookSearch, principal.user.id, pageable, principal.user.restrictions)
val bookPage = bookDtoRepository.findAll(bookSearch, SearchContext(principal.user), pageable)
val uriBuilder = uriBuilder(ROUTE_BOOKS_LATEST)
@ -545,15 +548,17 @@ class OpdsController(
contentRestrictionChecker.checkContentRestriction(principal.user, series)
val bookSearch =
BookSearchWithReadProgress(
seriesIds = listOf(id),
mediaStatus = setOf(Media.Status.READY),
deleted = false,
BookSearch(
SearchCondition.AllOfBook(
SearchCondition.SeriesId(SearchOperator.Is(series.id)),
SearchCondition.MediaStatus(SearchOperator.Is(Media.Status.READY)),
SearchCondition.Deleted(SearchOperator.IsFalse),
),
)
val pageable = PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.asc("metadata.numberSort")))
val entries =
bookDtoRepository.findAll(bookSearch, principal.user.id, pageable, principal.user.restrictions)
bookDtoRepository.findAll(bookSearch, SearchContext(principal.user), pageable)
.map { it.toOpdsEntry(mediaRepository.findById(it.id)) }
val uriBuilder = uriBuilder("series/$id")
@ -584,15 +589,17 @@ class OpdsController(
if (!principal.user.canAccessLibrary(library)) throw ResponseStatusException(HttpStatus.FORBIDDEN)
val seriesSearch =
SeriesSearchWithReadProgress(
libraryIds = setOf(library.id),
deleted = false,
SeriesSearch(
SearchCondition.AllOfSeries(
SearchCondition.LibraryId(SearchOperator.Is(library.id)),
SearchCondition.Deleted(SearchOperator.IsFalse),
),
)
val pageable = PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.asc("metadata.titleSort")))
val entries =
seriesDtoRepository.findAll(seriesSearch, principal.user.id, pageable, principal.user.restrictions)
seriesDtoRepository.findAll(seriesSearch, SearchContext(principal.user), pageable)
.map { it.toOpdsEntry() }
val uriBuilder = uriBuilder("libraries/$id")
@ -628,13 +635,15 @@ class OpdsController(
val pageable = PageRequest.of(page.pageNumber, page.pageSize, sort)
val seriesSearch =
SeriesSearchWithReadProgress(
libraryIds = principal.user.getAuthorizedLibraryIds(null),
deleted = false,
SeriesSearch(
SearchCondition.AllOfSeries(
SearchCondition.CollectionId(SearchOperator.Is(collection.id)),
SearchCondition.Deleted(SearchOperator.IsFalse),
),
)
val entries =
seriesDtoRepository.findAllByCollectionId(collection.id, seriesSearch, principal.user.id, pageable, principal.user.restrictions)
seriesDtoRepository.findAll(seriesSearch, SearchContext(principal.user), pageable)
.map { it.toOpdsEntry() }
val uriBuilder = uriBuilder("collections/$id")
@ -670,20 +679,14 @@ class OpdsController(
val pageable = PageRequest.of(page.pageNumber, page.pageSize, sort)
val bookSearch =
BookSearchWithReadProgress(
mediaStatus = setOf(Media.Status.READY),
deleted = false,
)
val booksPage =
bookDtoRepository.findAllByReadListId(
readList.id,
principal.user.id,
principal.user.getAuthorizedLibraryIds(null),
bookSearch,
pageable,
principal.user.restrictions,
BookSearch(
SearchCondition.AllOfBook(
SearchCondition.ReadListId(SearchOperator.Is(readList.id)),
SearchCondition.MediaStatus(SearchOperator.Is(Media.Status.READY)),
SearchCondition.Deleted(SearchOperator.IsFalse),
),
)
val booksPage = bookDtoRepository.findAll(bookSearch, SearchContext(principal.user), pageable)
val entries =
booksPage.map { bookDto ->

View file

@ -4,15 +4,18 @@ import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.media.Content
import io.swagger.v3.oas.annotations.media.Schema
import io.swagger.v3.oas.annotations.responses.ApiResponse
import org.gotson.komga.domain.model.BookSearchWithReadProgress
import org.gotson.komga.domain.model.BookSearch
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.Library
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.ROLE_PAGE_STREAMING
import org.gotson.komga.domain.model.ReadList
import org.gotson.komga.domain.model.ReadStatus
import org.gotson.komga.domain.model.SearchCondition
import org.gotson.komga.domain.model.SearchContext
import org.gotson.komga.domain.model.SearchOperator
import org.gotson.komga.domain.model.SeriesCollection
import org.gotson.komga.domain.model.SeriesSearchWithReadProgress
import org.gotson.komga.domain.model.SeriesSearch
import org.gotson.komga.domain.persistence.LibraryRepository
import org.gotson.komga.domain.persistence.ReadListRepository
import org.gotson.komga.domain.persistence.ReferentialRepository
@ -181,15 +184,17 @@ class Opds2Controller(
val keepReading =
bookDtoRepository.findAll(
BookSearchWithReadProgress(
libraryIds = authorizedLibraryIds,
readStatus = setOf(ReadStatus.IN_PROGRESS),
mediaStatus = setOf(Media.Status.READY),
deleted = false,
BookSearch(
SearchCondition.AllOfBook(
buildList {
if (library != null) add(SearchCondition.LibraryId(SearchOperator.Is(library.id)))
add(SearchCondition.MediaStatus(SearchOperator.Is(Media.Status.READY)))
add(SearchCondition.Deleted(SearchOperator.IsFalse))
},
),
),
principal.user.id,
SearchContext(principal.user),
PageRequest.of(0, RECOMMENDED_ITEMS_NUMBER, Sort.by(Sort.Order.desc("readProgress.readDate"))),
principal.user.restrictions,
).map { opdsGenerator.toOpdsPublicationDto(it) }
val onDeck =
@ -202,26 +207,32 @@ class Opds2Controller(
val latestBooks =
bookDtoRepository.findAll(
BookSearchWithReadProgress(
libraryIds = authorizedLibraryIds,
mediaStatus = setOf(Media.Status.READY),
deleted = false,
BookSearch(
SearchCondition.AllOfBook(
buildList {
if (library != null) add(SearchCondition.LibraryId(SearchOperator.Is(library.id)))
add(SearchCondition.MediaStatus(SearchOperator.Is(Media.Status.READY)))
add(SearchCondition.Deleted(SearchOperator.IsFalse))
},
),
),
principal.user.id,
SearchContext(principal.user),
PageRequest.of(0, RECOMMENDED_ITEMS_NUMBER, Sort.by(Sort.Order.desc("createdDate"))),
principal.user.restrictions,
).map { opdsGenerator.toOpdsPublicationDto(it) }
val latestSeries =
seriesDtoRepository.findAll(
SeriesSearchWithReadProgress(
libraryIds = authorizedLibraryIds,
deleted = false,
oneshot = false,
SeriesSearch(
SearchCondition.AllOfSeries(
buildList {
if (library != null) add(SearchCondition.LibraryId(SearchOperator.Is(library.id)))
add(SearchCondition.Deleted(SearchOperator.IsFalse))
add(SearchCondition.OneShot(SearchOperator.IsFalse))
},
),
),
principal.user.id,
SearchContext(principal.user),
PageRequest.of(0, RECOMMENDED_ITEMS_NUMBER, Sort.by(Sort.Order.desc("lastModified"))),
principal.user.restrictions,
).map { it.toWPLinkDto() }
val uriBuilder = uriBuilder("libraries${if (library != null) "/${library.id}" else ""}")
@ -285,19 +296,19 @@ class Opds2Controller(
@PathVariable(name = "id", required = false) libraryId: String?,
@Parameter(hidden = true) page: Pageable,
): FeedDto {
val (library, authorizedLibraryIds) = checkLibraryAccess(libraryId, principal)
val (library, _) = checkLibraryAccess(libraryId, principal)
val entries =
bookDtoRepository.findAll(
BookSearchWithReadProgress(
libraryIds = authorizedLibraryIds,
readStatus = setOf(ReadStatus.IN_PROGRESS),
mediaStatus = setOf(Media.Status.READY),
deleted = false,
BookSearch(
SearchCondition.AllOfBook(
SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.IN_PROGRESS)),
SearchCondition.MediaStatus(SearchOperator.Is(Media.Status.READY)),
SearchCondition.Deleted(SearchOperator.IsFalse),
),
),
principal.user.id,
SearchContext(principal.user),
PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.desc("readProgress.readDate"))),
principal.user.restrictions,
).map { opdsGenerator.toOpdsPublicationDto(it) }
val uriBuilder = uriBuilder("libraries${if (library != null) "/${library.id}" else ""}/keep-reading")
@ -364,18 +375,18 @@ class Opds2Controller(
@PathVariable(name = "id", required = false) libraryId: String?,
@Parameter(hidden = true) page: Pageable,
): FeedDto {
val (library, authorizedLibraryIds) = checkLibraryAccess(libraryId, principal)
val (library, _) = checkLibraryAccess(libraryId, principal)
val entries =
bookDtoRepository.findAll(
BookSearchWithReadProgress(
libraryIds = authorizedLibraryIds,
mediaStatus = setOf(Media.Status.READY),
deleted = false,
BookSearch(
SearchCondition.AllOfBook(
SearchCondition.MediaStatus(SearchOperator.Is(Media.Status.READY)),
SearchCondition.Deleted(SearchOperator.IsFalse),
),
),
principal.user.id,
SearchContext(principal.user),
PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.desc("createdDate"))),
principal.user.restrictions,
).map { opdsGenerator.toOpdsPublicationDto(it) }
val uriBuilder = uriBuilder("libraries${if (library != null) "/${library.id}" else ""}/books/latest")
@ -409,14 +420,17 @@ class Opds2Controller(
val entries =
seriesDtoRepository.findAll(
SeriesSearchWithReadProgress(
libraryIds = authorizedLibraryIds,
deleted = false,
oneshot = false,
SeriesSearch(
SearchCondition.AllOfSeries(
buildList {
if (library != null) add(SearchCondition.LibraryId(SearchOperator.Is(library.id)))
add(SearchCondition.Deleted(SearchOperator.IsFalse))
add(SearchCondition.OneShot(SearchOperator.IsFalse))
},
),
),
principal.user.id,
SearchContext(principal.user),
PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.desc("lastModified"))),
principal.user.restrictions,
).map { it.toWPLinkDto() }
val uriBuilder = uriBuilder("libraries${if (library != null) "/${library.id}" else ""}/books/latest")
@ -450,15 +464,21 @@ class Opds2Controller(
val (library, authorizedLibraryIds) = checkLibraryAccess(libraryId, principal)
val seriesSearch =
SeriesSearchWithReadProgress(
libraryIds = authorizedLibraryIds,
publishers = publishers,
deleted = false,
SeriesSearch(
SearchCondition.AllOfSeries(
buildList {
if (library != null) add(SearchCondition.LibraryId(SearchOperator.Is(library.id)))
if (!publishers.isNullOrEmpty()) {
add(SearchCondition.AllOfSeries(publishers.map { SearchCondition.Publisher(SearchOperator.Is(it)) }))
}
add(SearchCondition.Deleted(SearchOperator.IsFalse))
},
),
)
val pageable = PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.asc("metadata.titleSort")))
val entries = seriesDtoRepository.findAll(seriesSearch, principal.user.id, pageable, principal.user.restrictions).map { it.toWPLinkDto() }
val entries = seriesDtoRepository.findAll(seriesSearch, SearchContext(principal.user), pageable).map { it.toWPLinkDto() }
val uriBuilder = uriBuilder("libraries${if (library != null) "/${library.id}" else ""}/browse")
@ -551,12 +571,14 @@ class Opds2Controller(
val pageable = PageRequest.of(page.pageNumber, page.pageSize, sort)
val seriesSearch =
SeriesSearchWithReadProgress(
libraryIds = principal.user.getAuthorizedLibraryIds(null),
deleted = false,
SeriesSearch(
SearchCondition.AllOfSeries(
SearchCondition.CollectionId(SearchOperator.Is(collection.id)),
SearchCondition.Deleted(SearchOperator.IsFalse),
),
)
val entries = seriesDtoRepository.findAllByCollectionId(collection.id, seriesSearch, principal.user.id, pageable, principal.user.restrictions).map { it.toWPLinkDto() }
val entries = seriesDtoRepository.findAll(seriesSearch, SearchContext(principal.user), pageable).map { it.toWPLinkDto() }
val uriBuilder = uriBuilder("collections/$id")
@ -634,20 +656,14 @@ class Opds2Controller(
val pageable = PageRequest.of(page.pageNumber, page.pageSize, sort)
val bookSearch =
BookSearchWithReadProgress(
mediaStatus = setOf(Media.Status.READY),
deleted = false,
)
val booksPage =
bookDtoRepository.findAllByReadListId(
readList.id,
principal.user.id,
principal.user.getAuthorizedLibraryIds(null),
bookSearch,
pageable,
principal.user.restrictions,
BookSearch(
SearchCondition.AllOfBook(
SearchCondition.ReadListId(SearchOperator.Is(readList.id)),
SearchCondition.MediaStatus(SearchOperator.Is(Media.Status.READY)),
SearchCondition.Deleted(SearchOperator.IsFalse),
),
)
val booksPage = bookDtoRepository.findAll(bookSearch, SearchContext(principal.user), pageable)
val entries = booksPage.map { opdsGenerator.toOpdsPublicationDto(it) }
@ -695,19 +711,23 @@ class Opds2Controller(
@RequestParam(name = "tag", required = false) tag: String? = null,
@Parameter(hidden = true) page: Pageable,
): FeedDto =
seriesDtoRepository.findByIdOrNull(id, "principal.user.id")?.let { series ->
seriesDtoRepository.findByIdOrNull(id, principal.user.id)?.let { series ->
contentRestrictionChecker.checkContentRestriction(principal.user, series)
val bookSearch =
BookSearchWithReadProgress(
seriesIds = listOf(id),
mediaStatus = setOf(Media.Status.READY),
tags = if (tag != null) listOf(tag) else null,
deleted = false,
BookSearch(
SearchCondition.AllOfBook(
buildList {
add(SearchCondition.SeriesId(SearchOperator.Is(series.id)))
add(SearchCondition.MediaStatus(SearchOperator.Is(Media.Status.READY)))
add(SearchCondition.Deleted(SearchOperator.IsFalse))
tag?.let { add(SearchCondition.Tag(SearchOperator.Is(it))) }
},
),
)
val pageable = PageRequest.of(page.pageNumber, page.pageSize, Sort.by(Sort.Order.asc("metadata.numberSort")))
val entries = bookDtoRepository.findAll(bookSearch, principal.user.id, pageable, principal.user.restrictions).map { opdsGenerator.toOpdsPublicationDto(it) }
val entries = bookDtoRepository.findAll(bookSearch, SearchContext(principal.user), pageable).map { opdsGenerator.toOpdsPublicationDto(it) }
val uriBuilder = uriBuilder("series/$id")
@ -750,30 +770,39 @@ class Opds2Controller(
@RequestParam(name = "query", required = false) query: String? = null,
): FeedDto {
val pageable = PageRequest.of(0, 20, Sort.by("relevance"))
val queryTerms = query?.split("\\s+".toRegex())
val resultsSeries =
seriesDtoRepository.findAll(
SeriesSearchWithReadProgress(
libraryIds = principal.user.getAuthorizedLibraryIds(null),
searchTerm = query,
oneshot = false,
deleted = false,
SeriesSearch(
SearchCondition.AllOfSeries(
buildList {
add(SearchCondition.OneShot(SearchOperator.IsFalse))
add(SearchCondition.Deleted(SearchOperator.IsFalse))
if (!queryTerms.isNullOrEmpty()) {
add(SearchCondition.AllOfSeries(queryTerms.map { SearchCondition.Title(SearchOperator.Contains(it)) }))
}
},
),
),
principal.user.id,
SearchContext(principal.user),
pageable,
principal.user.restrictions,
).map { it.toWPLinkDto() }
val resultsBooks =
bookDtoRepository.findAll(
BookSearchWithReadProgress(
libraryIds = principal.user.getAuthorizedLibraryIds(null),
searchTerm = query,
deleted = false,
BookSearch(
SearchCondition.AllOfBook(
buildList {
add(SearchCondition.Deleted(SearchOperator.IsFalse))
if (!queryTerms.isNullOrEmpty()) {
add(SearchCondition.AllOfBook(queryTerms.map { SearchCondition.Title(SearchOperator.Contains(it)) }))
}
},
),
),
principal.user.id,
SearchContext(principal.user),
pageable,
principal.user.restrictions,
).map { opdsGenerator.toOpdsPublicationDto(it) }
val resultsCollections =

View file

@ -1,30 +1,25 @@
package org.gotson.komga.interfaces.api.persistence
import org.gotson.komga.domain.model.BookSearchWithReadProgress
import org.gotson.komga.domain.model.BookSearch
import org.gotson.komga.domain.model.ContentRestrictions
import org.gotson.komga.domain.model.ReadList
import org.gotson.komga.domain.model.SearchContext
import org.gotson.komga.interfaces.api.rest.dto.BookDto
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
interface BookDtoRepository {
fun findAll(pageable: Pageable): Page<BookDto>
fun findAll(
search: BookSearchWithReadProgress,
userId: String,
context: SearchContext,
pageable: Pageable,
restrictions: ContentRestrictions = ContentRestrictions(),
): Page<BookDto>
/**
* Find books that are part of a readlist, optionally filtered by library
*/
fun findAllByReadListId(
readListId: String,
userId: String,
filterOnLibraryIds: Collection<String>?,
search: BookSearchWithReadProgress,
fun findAll(
search: BookSearch,
context: SearchContext,
pageable: Pageable,
restrictions: ContentRestrictions = ContentRestrictions(),
): Page<BookDto>
fun findByIdOrNull(

View file

@ -1,7 +1,7 @@
package org.gotson.komga.interfaces.api.persistence
import org.gotson.komga.domain.model.ContentRestrictions
import org.gotson.komga.domain.model.SeriesSearchWithReadProgress
import org.gotson.komga.domain.model.SearchContext
import org.gotson.komga.domain.model.SeriesSearch
import org.gotson.komga.interfaces.api.rest.dto.GroupCountDto
import org.gotson.komga.interfaces.api.rest.dto.SeriesDto
import org.springframework.data.domain.Page
@ -14,30 +14,28 @@ interface SeriesDtoRepository {
): SeriesDto?
fun findAll(
search: SeriesSearchWithReadProgress,
userId: String,
pageable: Pageable,
restrictions: ContentRestrictions = ContentRestrictions(),
): Page<SeriesDto>
fun findAllByCollectionId(
collectionId: String,
search: SeriesSearchWithReadProgress,
userId: String,
fun findAll(
context: SearchContext,
pageable: Pageable,
): Page<SeriesDto>
fun findAll(
search: SeriesSearch,
context: SearchContext,
pageable: Pageable,
restrictions: ContentRestrictions = ContentRestrictions(),
): Page<SeriesDto>
fun findAllRecentlyUpdated(
search: SeriesSearchWithReadProgress,
userId: String,
restrictions: ContentRestrictions,
search: SeriesSearch,
context: SearchContext,
pageable: Pageable,
): Page<SeriesDto>
fun countByFirstCharacter(
search: SeriesSearchWithReadProgress,
userId: String,
restrictions: ContentRestrictions,
search: SeriesSearch,
context: SearchContext,
): List<GroupCountDto>
}

View file

@ -12,7 +12,7 @@ import org.gotson.komga.application.tasks.HIGHEST_PRIORITY
import org.gotson.komga.application.tasks.HIGH_PRIORITY
import org.gotson.komga.application.tasks.LOWEST_PRIORITY
import org.gotson.komga.application.tasks.TaskEmitter
import org.gotson.komga.domain.model.BookSearchWithReadProgress
import org.gotson.komga.domain.model.BookSearch
import org.gotson.komga.domain.model.Dimension
import org.gotson.komga.domain.model.DomainEvent
import org.gotson.komga.domain.model.ImageConversionException
@ -24,6 +24,9 @@ import org.gotson.komga.domain.model.MediaProfile
import org.gotson.komga.domain.model.ROLE_ADMIN
import org.gotson.komga.domain.model.ROLE_PAGE_STREAMING
import org.gotson.komga.domain.model.ReadStatus
import org.gotson.komga.domain.model.SearchCondition
import org.gotson.komga.domain.model.SearchContext
import org.gotson.komga.domain.model.SearchOperator
import org.gotson.komga.domain.model.ThumbnailBook
import org.gotson.komga.domain.persistence.BookMetadataRepository
import org.gotson.komga.domain.persistence.BookRepository
@ -92,6 +95,7 @@ import org.springframework.web.multipart.MultipartFile
import org.springframework.web.server.ResponseStatusException
import java.nio.file.NoSuchFileException
import java.time.LocalDate
import java.time.ZoneOffset
private val logger = KotlinLogging.logger {}
@ -148,16 +152,20 @@ class BookController(
)
val bookSearch =
BookSearchWithReadProgress(
libraryIds = principal.user.getAuthorizedLibraryIds(libraryIds),
searchTerm = searchTerm,
mediaStatus = mediaStatus,
readStatus = readStatus,
releasedAfter = releasedAfter,
tags = tags,
BookSearch(
SearchCondition.AllOfBook(
buildList {
if (!libraryIds.isNullOrEmpty()) add(SearchCondition.AnyOfBook(libraryIds.map { SearchCondition.LibraryId(SearchOperator.Is(it)) }))
if (!mediaStatus.isNullOrEmpty()) add(SearchCondition.AnyOfBook(mediaStatus.map { SearchCondition.MediaStatus(SearchOperator.Is(it)) }))
if (!readStatus.isNullOrEmpty()) add(SearchCondition.AnyOfBook(readStatus.map { SearchCondition.ReadStatus(SearchOperator.Is(it)) }))
if (!tags.isNullOrEmpty()) add(SearchCondition.AnyOfBook(tags.map { SearchCondition.Tag(SearchOperator.Is(it)) }))
releasedAfter?.let { add(SearchCondition.ReleaseDate(SearchOperator.After(it.atStartOfDay(ZoneOffset.UTC)))) }
},
),
searchTerm,
)
return bookDtoRepository.findAll(bookSearch, principal.user.id, pageRequest, principal.user.restrictions)
return bookDtoRepository.findAll(bookSearch, SearchContext(principal.user), pageRequest)
.map { it.restrictUrl(!principal.user.roleAdmin) }
}
@ -182,12 +190,8 @@ class BookController(
)
return bookDtoRepository.findAll(
BookSearchWithReadProgress(
libraryIds = principal.user.getAuthorizedLibraryIds(null),
),
principal.user.id,
SearchContext(principal.user),
pageRequest,
principal.user.restrictions,
).map { it.restrictUrl(!principal.user.roleAdmin) }
}

View file

@ -5,12 +5,14 @@ import jakarta.validation.Valid
import org.gotson.komga.application.tasks.HIGHEST_PRIORITY
import org.gotson.komga.application.tasks.HIGH_PRIORITY
import org.gotson.komga.application.tasks.TaskEmitter
import org.gotson.komga.domain.model.BookSearch
import org.gotson.komga.domain.model.DirectoryNotFoundException
import org.gotson.komga.domain.model.DuplicateNameException
import org.gotson.komga.domain.model.Library
import org.gotson.komga.domain.model.PathContainedInPath
import org.gotson.komga.domain.model.ROLE_ADMIN
import org.gotson.komga.domain.model.SearchCondition
import org.gotson.komga.domain.model.SearchContext
import org.gotson.komga.domain.model.SearchOperator
import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.LibraryRepository
import org.gotson.komga.domain.persistence.SeriesRepository
@ -22,6 +24,7 @@ import org.gotson.komga.interfaces.api.rest.dto.LibraryDto
import org.gotson.komga.interfaces.api.rest.dto.LibraryUpdateDto
import org.gotson.komga.interfaces.api.rest.dto.toDomain
import org.gotson.komga.interfaces.api.rest.dto.toDto
import org.springframework.data.domain.Pageable
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.security.access.prepost.PreAuthorize
@ -223,7 +226,13 @@ class LibraryController(
fun analyze(
@PathVariable libraryId: String,
) {
taskEmitter.analyzeBook(bookRepository.findAll(BookSearch(libraryIds = listOf(libraryId))), HIGH_PRIORITY)
val books =
bookRepository.findAll(
SearchCondition.LibraryId(SearchOperator.Is(libraryId)),
SearchContext.empty(),
Pageable.unpaged(),
).content
taskEmitter.analyzeBook(books, HIGH_PRIORITY)
}
@PostMapping("{libraryId}/metadata/refresh")
@ -232,7 +241,12 @@ class LibraryController(
fun refreshMetadata(
@PathVariable libraryId: String,
) {
val books = bookRepository.findAll(BookSearch(libraryIds = listOf(libraryId)))
val books =
bookRepository.findAll(
SearchCondition.LibraryId(SearchOperator.Is(libraryId)),
SearchContext.empty(),
Pageable.unpaged(),
).content
taskEmitter.refreshBookMetadata(books, priority = HIGH_PRIORITY)
taskEmitter.refreshBookLocalArtwork(books, priority = HIGH_PRIORITY)
taskEmitter.refreshSeriesLocalArtwork(seriesRepository.findAllIdsByLibraryId(libraryId), priority = HIGH_PRIORITY)

View file

@ -11,7 +11,7 @@ import org.apache.commons.compress.archivers.zip.ZipArchiveEntry
import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream
import org.apache.commons.io.IOUtils
import org.gotson.komga.domain.model.Author
import org.gotson.komga.domain.model.BookSearchWithReadProgress
import org.gotson.komga.domain.model.BookSearch
import org.gotson.komga.domain.model.CodedException
import org.gotson.komga.domain.model.Dimension
import org.gotson.komga.domain.model.DomainEvent
@ -22,6 +22,9 @@ import org.gotson.komga.domain.model.ROLE_ADMIN
import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD
import org.gotson.komga.domain.model.ReadList
import org.gotson.komga.domain.model.ReadStatus
import org.gotson.komga.domain.model.SearchCondition
import org.gotson.komga.domain.model.SearchContext
import org.gotson.komga.domain.model.SearchOperator
import org.gotson.komga.domain.model.ThumbnailReadList
import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.ReadListRepository
@ -329,23 +332,20 @@ class ReadListController(
)
val bookSearch =
BookSearchWithReadProgress(
libraryIds = principal.user.getAuthorizedLibraryIds(libraryIds),
readStatus = readStatus,
mediaStatus = mediaStatus,
deleted = deleted,
tags = tags,
authors = authors,
BookSearch(
SearchCondition.AllOfBook(
buildList {
add(SearchCondition.ReadListId(SearchOperator.Is(readList.id)))
if (!libraryIds.isNullOrEmpty()) add(SearchCondition.AnyOfBook(libraryIds.map { SearchCondition.LibraryId(SearchOperator.Is(it)) }))
if (!readStatus.isNullOrEmpty()) add(SearchCondition.AnyOfBook(readStatus.map { SearchCondition.ReadStatus(SearchOperator.Is(it)) }))
if (!mediaStatus.isNullOrEmpty()) add(SearchCondition.AnyOfBook(mediaStatus.map { SearchCondition.MediaStatus(SearchOperator.Is(it)) }))
if (!tags.isNullOrEmpty()) add(SearchCondition.AnyOfBook(tags.map { SearchCondition.Tag(SearchOperator.Is(it)) }))
if (!authors.isNullOrEmpty()) add(SearchCondition.AnyOfBook(authors.map { SearchCondition.Author(SearchOperator.Is(SearchCondition.AuthorMatch(it.name, it.role))) }))
deleted?.let { add(SearchCondition.Deleted(if (deleted) SearchOperator.IsTrue else SearchOperator.IsFalse)) }
},
),
)
bookDtoRepository.findAllByReadListId(
readList.id,
principal.user.id,
principal.user.getAuthorizedLibraryIds(null),
bookSearch,
pageRequest,
principal.user.restrictions,
)
bookDtoRepository.findAll(bookSearch, SearchContext(principal.user), pageRequest)
.map { it.restrictUrl(!principal.user.roleAdmin) }
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@ -401,14 +401,8 @@ class ReadListController(
@AuthenticationPrincipal principal: KomgaPrincipal,
) {
readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { readList ->
bookDtoRepository.findAllByReadListId(
readList.id,
principal.user.id,
principal.user.getAuthorizedLibraryIds(null),
BookSearchWithReadProgress(),
UnpagedSorted(Sort.by(Sort.Order.asc("readList.number"))),
principal.user.restrictions,
).filterIndexed { index, _ -> index < readProgress.lastBookRead }
bookDtoRepository.findAll(BookSearch(SearchCondition.ReadListId(SearchOperator.Is(readList.id))), SearchContext(principal.user), UnpagedSorted(Sort.by(Sort.Order.asc("readList.number"))))
.filterIndexed { index, _ -> index < readProgress.lastBookRead }
.forEach { book ->
if (book.readProgress?.completed != true)
bookLifecycle.markReadProgressCompleted(book.id, principal.user)

View file

@ -12,9 +12,12 @@ import org.gotson.komga.domain.model.DomainEvent
import org.gotson.komga.domain.model.DuplicateNameException
import org.gotson.komga.domain.model.ROLE_ADMIN
import org.gotson.komga.domain.model.ReadStatus
import org.gotson.komga.domain.model.SearchCondition
import org.gotson.komga.domain.model.SearchContext
import org.gotson.komga.domain.model.SearchOperator
import org.gotson.komga.domain.model.SeriesCollection
import org.gotson.komga.domain.model.SeriesMetadata
import org.gotson.komga.domain.model.SeriesSearchWithReadProgress
import org.gotson.komga.domain.model.SeriesSearch
import org.gotson.komga.domain.model.ThumbnailSeriesCollection
import org.gotson.komga.domain.persistence.SeriesCollectionRepository
import org.gotson.komga.domain.persistence.ThumbnailSeriesCollectionRepository
@ -58,6 +61,8 @@ import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
import org.springframework.web.server.ResponseStatusException
import java.time.ZoneOffset
import java.time.ZonedDateTime
import java.util.concurrent.TimeUnit
private val logger = KotlinLogging.logger {}
@ -296,23 +301,38 @@ class SeriesCollectionController(
sort,
)
val seriesSearch =
SeriesSearchWithReadProgress(
libraryIds = principal.user.getAuthorizedLibraryIds(libraryIds),
metadataStatus = metadataStatus,
publishers = publishers,
deleted = deleted,
complete = complete,
languages = languages,
genres = genres,
tags = tags,
ageRatings = ageRatings?.map { it.toIntOrNull() },
releaseYears = releaseYears,
readStatus = readStatus,
authors = authors,
val search =
SeriesSearch(
SearchCondition.AllOfSeries(
buildList {
add(SearchCondition.CollectionId(SearchOperator.Is(collection.id)))
if (!libraryIds.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(libraryIds.map { SearchCondition.LibraryId(SearchOperator.Is(it)) }))
deleted?.let { add(SearchCondition.Deleted(if (it) SearchOperator.IsTrue else SearchOperator.IsFalse)) }
complete?.let { add(SearchCondition.Complete(if (it) SearchOperator.IsTrue else SearchOperator.IsFalse)) }
if (!metadataStatus.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(metadataStatus.map { SearchCondition.SeriesStatus(SearchOperator.Is(it)) }))
if (!publishers.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(publishers.map { SearchCondition.Publisher(SearchOperator.Is(it)) }))
if (!languages.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(languages.map { SearchCondition.Language(SearchOperator.Is(it)) }))
if (!tags.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(tags.map { SearchCondition.Tag(SearchOperator.Is(it)) }))
if (!genres.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(genres.map { SearchCondition.Genre(SearchOperator.Is(it)) }))
if (!ageRatings.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(ageRatings.map { it.toIntOrNull()?.let { ageRating -> SearchCondition.AgeRating(SearchOperator.Is(ageRating)) } ?: SearchCondition.AgeRating(SearchOperator.IsNullT()) }))
if (!releaseYears.isNullOrEmpty())
add(
SearchCondition.AnyOfSeries(
releaseYears.mapNotNull { it.toIntOrNull() }.map { releaseYear ->
SearchCondition.AllOfSeries(
SearchCondition.ReleaseDate(SearchOperator.After(ZonedDateTime.of(releaseYear - 1, 12, 31, 12, 0, 0, 0, ZoneOffset.UTC))),
SearchCondition.ReleaseDate(SearchOperator.Before(ZonedDateTime.of(releaseYear + 1, 1, 1, 12, 0, 0, 0, ZoneOffset.UTC))),
)
},
),
)
if (!readStatus.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(readStatus.map { SearchCondition.ReadStatus(SearchOperator.Is(it)) }))
if (!authors.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(authors.map { SearchCondition.Author(SearchOperator.Is(SearchCondition.AuthorMatch(it.name, it.role))) }))
},
),
)
seriesDtoRepository.findAllByCollectionId(collection.id, seriesSearch, principal.user.id, pageRequest, principal.user.restrictions)
seriesDtoRepository.findAll(search, SearchContext(principal.user), pageRequest)
.map { it.restrictUrl(!principal.user.roleAdmin) }
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}

View file

@ -18,7 +18,7 @@ import org.gotson.komga.application.tasks.HIGH_PRIORITY
import org.gotson.komga.application.tasks.TaskEmitter
import org.gotson.komga.domain.model.AlternateTitle
import org.gotson.komga.domain.model.Author
import org.gotson.komga.domain.model.BookSearchWithReadProgress
import org.gotson.komga.domain.model.BookSearch
import org.gotson.komga.domain.model.Dimension
import org.gotson.komga.domain.model.DomainEvent
import org.gotson.komga.domain.model.KomgaUser
@ -28,9 +28,12 @@ import org.gotson.komga.domain.model.MediaType.ZIP
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.SearchCondition
import org.gotson.komga.domain.model.SearchContext
import org.gotson.komga.domain.model.SearchField
import org.gotson.komga.domain.model.SearchOperator
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.model.ThumbnailSeries
import org.gotson.komga.domain.model.WebLink
import org.gotson.komga.domain.persistence.BookRepository
@ -93,6 +96,8 @@ import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBo
import java.io.OutputStream
import java.net.URI
import java.nio.charset.StandardCharsets.UTF_8
import java.time.ZoneOffset
import java.time.ZonedDateTime
import java.util.zip.Deflater
private val logger = KotlinLogging.logger {}
@ -116,6 +121,7 @@ class SeriesController(
private val thumbnailsSeriesRepository: ThumbnailSeriesRepository,
private val contentRestrictionChecker: ContentRestrictionChecker,
) {
@Deprecated("use /v1/series/list instead")
@PageableAsQueryParam
@AuthorsAsQueryParam
@Parameters(
@ -169,37 +175,83 @@ class SeriesController(
)
val seriesSearch =
SeriesSearchWithReadProgress(
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
}
SeriesSearch(
condition =
SearchCondition.AllOfSeries(
buildList {
if (!libraryIds.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(libraryIds.map { SearchCondition.LibraryId(SearchOperator.Is(it)) }))
if (!collectionIds.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(collectionIds.map { SearchCondition.CollectionId(SearchOperator.Is(it)) }))
if (!metadataStatus.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(metadataStatus.map { SearchCondition.SeriesStatus(SearchOperator.Is(it)) }))
if (!publishers.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(publishers.map { SearchCondition.Publisher(SearchOperator.Is(it)) }))
if (!languages.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(languages.map { SearchCondition.Language(SearchOperator.Is(it)) }))
if (!genres.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(genres.map { SearchCondition.Genre(SearchOperator.Is(it)) }))
if (!tags.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(tags.map { SearchCondition.Tag(SearchOperator.Is(it)) }))
if (!readStatus.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(readStatus.map { SearchCondition.ReadStatus(SearchOperator.Is(it)) }))
if (!authors.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(authors.map { SearchCondition.Author(SearchOperator.Is(SearchCondition.AuthorMatch(it.name, it.role))) }))
if (!ageRatings.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(ageRatings.map { it.toIntOrNull()?.let { ageRating -> SearchCondition.AgeRating(SearchOperator.Is(ageRating)) } ?: SearchCondition.AgeRating(SearchOperator.IsNullT()) }))
if (!releaseYears.isNullOrEmpty())
add(
SearchCondition.AnyOfSeries(
releaseYears.mapNotNull { it.toIntOrNull() }.map { releaseYear ->
SearchCondition.AllOfSeries(
SearchCondition.ReleaseDate(SearchOperator.After(ZonedDateTime.of(releaseYear - 1, 12, 31, 12, 0, 0, 0, ZoneOffset.UTC))),
SearchCondition.ReleaseDate(SearchOperator.Before(ZonedDateTime.of(releaseYear + 1, 1, 1, 12, 0, 0, 0, ZoneOffset.UTC))),
)
},
),
)
if (!sharingLabels.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(sharingLabels.map { SearchCondition.SharingLabel(SearchOperator.Is(it)) }))
oneshot?.let { add(SearchCondition.OneShot(if (it) SearchOperator.IsTrue else SearchOperator.IsFalse)) }
complete?.let { add(SearchCondition.Complete(if (it) SearchOperator.IsTrue else SearchOperator.IsFalse)) }
deleted?.let { add(SearchCondition.Deleted(if (it) SearchOperator.IsTrue else SearchOperator.IsFalse)) }
},
metadataStatus = metadataStatus,
publishers = publishers,
deleted = deleted,
complete = complete,
oneshot = oneshot,
languages = languages,
genres = genres,
tags = tags,
ageRatings = ageRatings?.map { it.toIntOrNull() },
releaseYears = releaseYears,
readStatus = readStatus,
authors = authors,
sharingLabels = sharingLabels,
),
fullTextSearch = searchTerm,
regexSearch =
searchRegex?.let {
when (it.second.lowercase()) {
"title" -> Pair(it.first, SearchField.TITLE)
"title_sort" -> Pair(it.first, SearchField.TITLE_SORT)
else -> null
}
},
)
return seriesDtoRepository.findAll(seriesSearch, principal.user.id, pageRequest, principal.user.restrictions)
return seriesDtoRepository.findAll(seriesSearch, SearchContext(principal.user), pageRequest)
.map { it.restrictUrl(!principal.user.roleAdmin) }
}
@PageableAsQueryParam
@PostMapping("v1/series/list")
fun getSeriesList(
@AuthenticationPrincipal principal: KomgaPrincipal,
@RequestBody search: SeriesSearch,
@RequestParam(name = "unpaged", required = false) unpaged: Boolean = false,
@Parameter(hidden = true) page: Pageable,
): Page<SeriesDto> {
val sort =
when {
page.sort.isSorted -> page.sort
!search.fullTextSearch.isNullOrBlank() -> Sort.by("relevance")
else -> Sort.unsorted()
}
val pageRequest =
if (unpaged)
UnpagedSorted(sort)
else
PageRequest.of(
page.pageNumber,
page.pageSize,
sort,
)
return seriesDtoRepository.findAll(search, SearchContext(principal.user), pageRequest)
.map { it.restrictUrl(!principal.user.roleAdmin) }
}
@Deprecated("use /v1/series/list/alphabetical-groups instead")
@AuthorsAsQueryParam
@Parameters(
Parameter(
@ -234,36 +286,58 @@ class SeriesController(
@Parameter(hidden = true) page: Pageable,
): List<GroupCountDto> {
val seriesSearch =
SeriesSearchWithReadProgress(
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
}
SeriesSearch(
condition =
SearchCondition.AllOfSeries(
buildList {
if (!libraryIds.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(libraryIds.map { SearchCondition.LibraryId(SearchOperator.Is(it)) }))
if (!collectionIds.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(collectionIds.map { SearchCondition.CollectionId(SearchOperator.Is(it)) }))
if (!metadataStatus.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(metadataStatus.map { SearchCondition.SeriesStatus(SearchOperator.Is(it)) }))
if (!publishers.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(publishers.map { SearchCondition.Publisher(SearchOperator.Is(it)) }))
if (!languages.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(languages.map { SearchCondition.Language(SearchOperator.Is(it)) }))
if (!genres.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(genres.map { SearchCondition.Genre(SearchOperator.Is(it)) }))
if (!tags.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(tags.map { SearchCondition.Tag(SearchOperator.Is(it)) }))
if (!readStatus.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(readStatus.map { SearchCondition.ReadStatus(SearchOperator.Is(it)) }))
if (!authors.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(authors.map { SearchCondition.Author(SearchOperator.Is(SearchCondition.AuthorMatch(it.name, it.role))) }))
if (!ageRatings.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(ageRatings.map { it.toIntOrNull()?.let { ageRating -> SearchCondition.AgeRating(SearchOperator.Is(ageRating)) } ?: SearchCondition.AgeRating(SearchOperator.IsNullT()) }))
if (!releaseYears.isNullOrEmpty())
add(
SearchCondition.AnyOfSeries(
releaseYears.mapNotNull { it.toIntOrNull() }.map { releaseYear ->
SearchCondition.AllOfSeries(
SearchCondition.ReleaseDate(SearchOperator.After(ZonedDateTime.of(releaseYear - 1, 12, 31, 12, 0, 0, 0, ZoneOffset.UTC))),
SearchCondition.ReleaseDate(SearchOperator.Before(ZonedDateTime.of(releaseYear + 1, 1, 1, 12, 0, 0, 0, ZoneOffset.UTC))),
)
},
),
)
if (!sharingLabels.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(sharingLabels.map { SearchCondition.SharingLabel(SearchOperator.Is(it)) }))
oneshot?.let { add(SearchCondition.OneShot(if (it) SearchOperator.IsTrue else SearchOperator.IsFalse)) }
complete?.let { add(SearchCondition.Complete(if (it) SearchOperator.IsTrue else SearchOperator.IsFalse)) }
deleted?.let { add(SearchCondition.Deleted(if (it) SearchOperator.IsTrue else SearchOperator.IsFalse)) }
},
metadataStatus = metadataStatus,
publishers = publishers,
deleted = deleted,
complete = complete,
oneshot = oneshot,
languages = languages,
genres = genres,
tags = tags,
ageRatings = ageRatings?.map { it.toIntOrNull() },
releaseYears = releaseYears,
readStatus = readStatus,
authors = authors,
sharingLabels = sharingLabels,
),
fullTextSearch = searchTerm,
regexSearch =
searchRegex?.let {
when (it.second.lowercase()) {
"title" -> Pair(it.first, SearchField.TITLE)
"title_sort" -> Pair(it.first, SearchField.TITLE_SORT)
else -> null
}
},
)
return seriesDtoRepository.countByFirstCharacter(seriesSearch, principal.user.id, principal.user.restrictions)
return seriesDtoRepository.countByFirstCharacter(seriesSearch, SearchContext(principal.user))
}
@PostMapping("v1/series/list/alphabetical-groups")
fun getSeriesListByAlphabeticalGroups(
@AuthenticationPrincipal principal: KomgaPrincipal,
@RequestBody search: SeriesSearch,
): List<GroupCountDto> = seriesDtoRepository.countByFirstCharacter(search, SearchContext(principal.user))
@Operation(description = "Return recently added or updated series.")
@PageableWithoutSortAsQueryParam
@GetMapping("v1/series/latest")
@ -288,14 +362,17 @@ class SeriesController(
)
return seriesDtoRepository.findAll(
SeriesSearchWithReadProgress(
libraryIds = principal.user.getAuthorizedLibraryIds(libraryIds),
deleted = deleted,
oneshot = oneshot,
SeriesSearch(
SearchCondition.AllOfSeries(
buildList {
if (!libraryIds.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(libraryIds.map { SearchCondition.LibraryId(SearchOperator.Is(it)) }))
deleted?.let { add(SearchCondition.Deleted(if (it) SearchOperator.IsTrue else SearchOperator.IsFalse)) }
oneshot?.let { add(SearchCondition.OneShot(if (it) SearchOperator.IsTrue else SearchOperator.IsFalse)) }
},
),
),
principal.user.id,
SearchContext(principal.user),
pageRequest,
principal.user.restrictions,
).map { it.restrictUrl(!principal.user.roleAdmin) }
}
@ -323,14 +400,17 @@ class SeriesController(
)
return seriesDtoRepository.findAll(
SeriesSearchWithReadProgress(
libraryIds = principal.user.getAuthorizedLibraryIds(libraryIds),
deleted = deleted,
oneshot = oneshot,
SeriesSearch(
SearchCondition.AllOfSeries(
buildList {
if (!libraryIds.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(libraryIds.map { SearchCondition.LibraryId(SearchOperator.Is(it)) }))
deleted?.let { add(SearchCondition.Deleted(if (it) SearchOperator.IsTrue else SearchOperator.IsFalse)) }
oneshot?.let { add(SearchCondition.OneShot(if (it) SearchOperator.IsTrue else SearchOperator.IsFalse)) }
},
),
),
principal.user.id,
SearchContext(principal.user),
pageRequest,
principal.user.restrictions,
).map { it.restrictUrl(!principal.user.roleAdmin) }
}
@ -358,13 +438,16 @@ class SeriesController(
)
return seriesDtoRepository.findAllRecentlyUpdated(
SeriesSearchWithReadProgress(
libraryIds = principal.user.getAuthorizedLibraryIds(libraryIds),
deleted = deleted,
oneshot = oneshot,
SeriesSearch(
SearchCondition.AllOfSeries(
buildList {
if (!libraryIds.isNullOrEmpty()) add(SearchCondition.AnyOfSeries(libraryIds.map { SearchCondition.LibraryId(SearchOperator.Is(it)) }))
deleted?.let { add(SearchCondition.Deleted(if (it) SearchOperator.IsTrue else SearchOperator.IsFalse)) }
oneshot?.let { add(SearchCondition.OneShot(if (it) SearchOperator.IsTrue else SearchOperator.IsFalse)) }
},
),
),
principal.user.id,
principal.user.restrictions,
SearchContext(principal.user),
pageRequest,
).map { it.restrictUrl(!principal.user.roleAdmin) }
}
@ -505,16 +588,22 @@ class SeriesController(
sort,
)
val search =
BookSearch(
SearchCondition.AllOfBook(
buildList {
add(SearchCondition.SeriesId(SearchOperator.Is(seriesId)))
if (!mediaStatus.isNullOrEmpty()) add(SearchCondition.AnyOfBook(mediaStatus.map { SearchCondition.MediaStatus(SearchOperator.Is(it)) }))
if (!readStatus.isNullOrEmpty()) add(SearchCondition.AnyOfBook(readStatus.map { SearchCondition.ReadStatus(SearchOperator.Is(it)) }))
if (!tags.isNullOrEmpty()) add(SearchCondition.AnyOfBook(tags.map { SearchCondition.Tag(SearchOperator.Is(it)) }))
if (!authors.isNullOrEmpty()) add(SearchCondition.AnyOfBook(authors.map { SearchCondition.Author(SearchOperator.Is(SearchCondition.AuthorMatch(it.name, it.role))) }))
deleted?.let { add(SearchCondition.Deleted(if (it) SearchOperator.IsTrue else SearchOperator.IsFalse)) }
},
),
)
return bookDtoRepository.findAll(
BookSearchWithReadProgress(
seriesIds = listOf(seriesId),
mediaStatus = mediaStatus,
deleted = deleted,
readStatus = readStatus,
tags = tags,
authors = authors,
),
principal.user.id,
search,
SearchContext(principal.user),
pageRequest,
).map { it.restrictUrl(!principal.user.roleAdmin) }
}
@ -583,41 +672,41 @@ class SeriesController(
ageRating = if (isSet("ageRating")) ageRating else existing.ageRating,
ageRatingLock = ageRatingLock ?: existing.ageRatingLock,
genres =
if (isSet("genres")) {
if (genres != null) genres!! else emptySet()
} else {
existing.genres
},
if (isSet("genres")) {
if (genres != null) genres!! else emptySet()
} else {
existing.genres
},
genresLock = genresLock ?: existing.genresLock,
tags =
if (isSet("tags")) {
if (tags != null) tags!! else emptySet()
} else {
existing.tags
},
if (isSet("tags")) {
if (tags != null) tags!! else emptySet()
} else {
existing.tags
},
tagsLock = tagsLock ?: existing.tagsLock,
totalBookCount = if (isSet("totalBookCount")) totalBookCount else existing.totalBookCount,
totalBookCountLock = totalBookCountLock ?: existing.totalBookCountLock,
sharingLabels =
if (isSet("sharingLabels")) {
if (sharingLabels != null) sharingLabels!! else emptySet()
} else {
existing.sharingLabels
},
if (isSet("sharingLabels")) {
if (sharingLabels != null) sharingLabels!! else emptySet()
} else {
existing.sharingLabels
},
sharingLabelsLock = sharingLabelsLock ?: existing.sharingLabelsLock,
links =
if (isSet("links")) {
if (links != null) links!!.map { WebLink(it.label!!, URI(it.url!!)) } else emptyList()
} else {
existing.links
},
if (isSet("links")) {
if (links != null) links!!.map { WebLink(it.label!!, URI(it.url!!)) } else emptyList()
} else {
existing.links
},
linksLock = linksLock ?: existing.linksLock,
alternateTitles =
if (isSet("alternateTitles")) {
if (alternateTitles != null) alternateTitles!!.map { AlternateTitle(it.label!!, it.title!!) } else emptyList()
} else {
existing.alternateTitles
},
if (isSet("alternateTitles")) {
if (alternateTitles != null) alternateTitles!!.map { AlternateTitle(it.label!!, it.title!!) } else emptyList()
} else {
existing.alternateTitles
},
alternateTitlesLock = alternateTitlesLock ?: existing.alternateTitlesLock,
)
}
@ -670,8 +759,8 @@ class SeriesController(
principal.user.checkContentRestriction(seriesId)
bookDtoRepository.findAll(
BookSearchWithReadProgress(seriesIds = listOf(seriesId)),
principal.user.id,
BookSearch(SearchCondition.SeriesId(SearchOperator.Is(seriesId))),
SearchContext(principal.user),
UnpagedSorted(Sort.by(Sort.Order.asc("metadata.numberSort"))),
).toList().filter { book -> book.metadata.numberSort <= readProgress.lastBookNumberSortRead }
.forEach { book ->

View file

@ -0,0 +1,77 @@
package org.gotson.komga.domain.model
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import org.assertj.core.api.Assertions.assertThat
import org.gotson.komga.domain.model.SearchCondition.AuthorMatch
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import java.time.Duration
import java.time.ZoneOffset
import java.time.ZonedDateTime
@SpringBootTest
class BookSearchTest(
@Autowired private val mapper: ObjectMapper,
) {
private val writer = mapper.writerWithDefaultPrettyPrinter()
@Test
fun `given bookSearch entity when serializing then it looks alright`() {
val search =
BookSearch(
SearchCondition.AllOfBook(
SearchCondition.AnyOfBook(
SearchCondition.LibraryId(SearchOperator.Is("library1")),
SearchCondition.LibraryId(SearchOperator.IsNot("library1")),
),
SearchCondition.SeriesId(SearchOperator.Is("series1")),
SearchCondition.SeriesId(SearchOperator.IsNot("series1")),
SearchCondition.ReadListId(SearchOperator.Is("readList1")),
SearchCondition.ReadListId(SearchOperator.IsNot("readList1")),
SearchCondition.Deleted(SearchOperator.IsFalse),
SearchCondition.Deleted(SearchOperator.IsTrue),
SearchCondition.Title(SearchOperator.BeginsWith("abc")),
SearchCondition.Title(SearchOperator.EndsWith("abc")),
SearchCondition.Title(SearchOperator.Is("abc")),
SearchCondition.Title(SearchOperator.IsNot("abc")),
SearchCondition.Title(SearchOperator.Contains("abc")),
SearchCondition.Title(SearchOperator.DoesNotContain("abc")),
SearchCondition.ReleaseDate(SearchOperator.Before(ZonedDateTime.now(ZoneOffset.UTC).minusMonths(1))),
SearchCondition.ReleaseDate(SearchOperator.After(ZonedDateTime.now(ZoneOffset.UTC).minusMonths(1))),
SearchCondition.ReleaseDate(SearchOperator.IsNotInTheLast(Duration.ofDays(5))),
SearchCondition.ReleaseDate(SearchOperator.IsInTheLast(Duration.ofDays(5))),
SearchCondition.ReleaseDate(SearchOperator.IsNotNull),
SearchCondition.ReleaseDate(SearchOperator.IsNull),
SearchCondition.NumberSort(SearchOperator.GreaterThan(5F)),
SearchCondition.NumberSort(SearchOperator.LessThan(5F)),
SearchCondition.NumberSort(SearchOperator.Is(5F)),
SearchCondition.NumberSort(SearchOperator.IsNot(5F)),
SearchCondition.Tag(SearchOperator.Is("fiction")),
SearchCondition.Tag(SearchOperator.IsNot("fantasy")),
SearchCondition.ReadStatus(SearchOperator.IsNot(ReadStatus.READ)),
SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.READ)),
SearchCondition.MediaStatus(SearchOperator.Is(Media.Status.READY)),
SearchCondition.MediaStatus(SearchOperator.IsNot(Media.Status.READY)),
SearchCondition.MediaProfile(SearchOperator.Is(MediaProfile.PDF)),
SearchCondition.MediaProfile(SearchOperator.IsNot(MediaProfile.PDF)),
SearchCondition.Author(SearchOperator.Is(AuthorMatch("john", "writer"))),
SearchCondition.Author(SearchOperator.IsNot(AuthorMatch("jack", "writer"))),
SearchCondition.Author(SearchOperator.Is(AuthorMatch(role = "writer"))),
SearchCondition.Author(SearchOperator.IsNot(AuthorMatch(name = "jim"))),
SearchCondition.Author(SearchOperator.IsNot(AuthorMatch())),
SearchCondition.OneShot(SearchOperator.IsFalse),
SearchCondition.OneShot(SearchOperator.IsTrue),
),
)
val json = writer.writeValueAsString(search)
println(json)
val entity = mapper.readValue<BookSearch>(json)
assertThat(entity).isEqualTo(search)
}
}

View file

@ -0,0 +1,97 @@
package org.gotson.komga.domain.model
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import org.assertj.core.api.Assertions.assertThat
import org.gotson.komga.domain.model.SearchCondition.AuthorMatch
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import java.time.Duration
import java.time.ZoneOffset
import java.time.ZonedDateTime
@SpringBootTest
class SeriesSearchTest(
@Autowired private val mapper: ObjectMapper,
) {
private val writer = mapper.writerWithDefaultPrettyPrinter()
@Test
fun `given seriesSearch entity when serializing then it looks alright`() {
val search =
SeriesSearch(
SearchCondition.AllOfSeries(
SearchCondition.AnyOfSeries(
emptyList()
),
SearchCondition.AnyOfSeries(
SearchCondition.LibraryId(SearchOperator.Is("library1")),
SearchCondition.LibraryId(SearchOperator.IsNot("library1")),
),
SearchCondition.CollectionId(SearchOperator.Is("collection1")),
SearchCondition.CollectionId(SearchOperator.IsNot("collection1")),
SearchCondition.Deleted(SearchOperator.IsFalse),
SearchCondition.Deleted(SearchOperator.IsTrue),
SearchCondition.Complete(SearchOperator.IsTrue),
SearchCondition.Complete(SearchOperator.IsFalse),
SearchCondition.ReleaseDate(SearchOperator.Before(ZonedDateTime.now(ZoneOffset.UTC).minusMonths(1))),
SearchCondition.ReleaseDate(SearchOperator.After(ZonedDateTime.now(ZoneOffset.UTC).minusMonths(1))),
SearchCondition.ReleaseDate(SearchOperator.IsNotInTheLast(Duration.ofDays(5))),
SearchCondition.ReleaseDate(SearchOperator.IsInTheLast(Duration.ofDays(5))),
SearchCondition.ReleaseDate(SearchOperator.IsNotNull),
SearchCondition.ReleaseDate(SearchOperator.IsNull),
SearchCondition.AgeRating(SearchOperator.LessThan(5)),
SearchCondition.AgeRating(SearchOperator.Is(5)),
SearchCondition.AgeRating(SearchOperator.IsNullT()),
SearchCondition.AgeRating(SearchOperator.IsNot(5)),
SearchCondition.AgeRating(SearchOperator.IsNotNullT()),
SearchCondition.Tag(SearchOperator.Is("fiction")),
SearchCondition.Tag(SearchOperator.IsNot("fantasy")),
SearchCondition.SharingLabel(SearchOperator.Is("label1")),
SearchCondition.SharingLabel(SearchOperator.IsNot("label1")),
SearchCondition.Publisher(SearchOperator.Is("publisher1")),
SearchCondition.Publisher(SearchOperator.IsNot("publisher1")),
SearchCondition.Language(SearchOperator.Is("en")),
SearchCondition.Language(SearchOperator.IsNot("en")),
SearchCondition.Genre(SearchOperator.Is("genre1")),
SearchCondition.Genre(SearchOperator.IsNot("genre1")),
SearchCondition.ReadStatus(SearchOperator.IsNot(ReadStatus.READ)),
SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.READ)),
SearchCondition.SeriesStatus(SearchOperator.Is(SeriesMetadata.Status.ENDED)),
SearchCondition.SeriesStatus(SearchOperator.IsNot(SeriesMetadata.Status.ONGOING)),
SearchCondition.Author(SearchOperator.Is(AuthorMatch("john", "writer"))),
SearchCondition.Author(SearchOperator.IsNot(AuthorMatch("jack", "writer"))),
SearchCondition.Author(SearchOperator.Is(AuthorMatch(role = "writer"))),
SearchCondition.Author(SearchOperator.IsNot(AuthorMatch(name = "jim"))),
SearchCondition.Author(SearchOperator.IsNot(AuthorMatch())),
SearchCondition.OneShot(SearchOperator.IsFalse),
SearchCondition.OneShot(SearchOperator.IsTrue),
SearchCondition.Title(SearchOperator.Is("abc")),
SearchCondition.Title(SearchOperator.IsNot("abc")),
SearchCondition.Title(SearchOperator.Contains("abc")),
SearchCondition.Title(SearchOperator.DoesNotContain("abc")),
SearchCondition.Title(SearchOperator.BeginsWith("abc")),
SearchCondition.Title(SearchOperator.DoesNotBeginWith("abc")),
SearchCondition.Title(SearchOperator.EndsWith("abc")),
SearchCondition.Title(SearchOperator.DoesNotEndWith("abc")),
SearchCondition.TitleSort(SearchOperator.Is("abc")),
SearchCondition.TitleSort(SearchOperator.IsNot("abc")),
SearchCondition.TitleSort(SearchOperator.Contains("abc")),
SearchCondition.TitleSort(SearchOperator.DoesNotContain("abc")),
SearchCondition.TitleSort(SearchOperator.BeginsWith("abc")),
SearchCondition.TitleSort(SearchOperator.DoesNotBeginWith("abc")),
SearchCondition.TitleSort(SearchOperator.EndsWith("abc")),
SearchCondition.TitleSort(SearchOperator.DoesNotEndWith("abc")),
),
)
val json = writer.writeValueAsString(search)
println(json)
val entity = mapper.readValue<SeriesSearch>(json)
assertThat(entity).isEqualTo(search)
}
}

View file

@ -190,12 +190,11 @@ class SyncPointLifecycleTest(
val book2 = makeBook("valid2", libraryId = library1.id)
val book3 = makeBook("valid3", libraryId = library1.id)
val series =
makeSeries(name = "series1", libraryId = library1.id).also { series ->
seriesLifecycle.createSeries(series).let { created ->
seriesLifecycle.addBooks(created, listOf(book1, book2, book3))
}
makeSeries(name = "series1", libraryId = library1.id).also { series ->
seriesLifecycle.createSeries(series).let { created ->
seriesLifecycle.addBooks(created, listOf(book1, book2, book3))
}
}
bookRepository.findAll().forEach { mediaRepository.findById(it.id).let { media -> mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = MediaType.EPUB.type)) } }
@ -231,12 +230,11 @@ class SyncPointLifecycleTest(
val book3 = makeBook("valid3", libraryId = library1.id)
val book4 = makeBook("no hash to hash", libraryId = library1.id)
val series =
makeSeries(name = "series1", libraryId = library1.id).also { series ->
seriesLifecycle.createSeries(series).let { created ->
seriesLifecycle.addBooks(created, listOf(book1, book2, book3))
}
makeSeries(name = "series1", libraryId = library1.id).also { series ->
seriesLifecycle.createSeries(series).let { created ->
seriesLifecycle.addBooks(created, listOf(book1, book2, book3))
}
}
bookRepository.findAll().forEach { mediaRepository.findById(it.id).let { media -> mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = MediaType.EPUB.type)) } }

View file

@ -2,7 +2,9 @@ package org.gotson.komga.infrastructure.jooq.main
import org.assertj.core.api.Assertions.assertThat
import org.gotson.komga.domain.model.Book
import org.gotson.komga.domain.model.BookSearch
import org.gotson.komga.domain.model.SearchCondition
import org.gotson.komga.domain.model.SearchContext
import org.gotson.komga.domain.model.SearchOperator
import org.gotson.komga.domain.model.makeBook
import org.gotson.komga.domain.model.makeLibrary
import org.gotson.komga.domain.model.makeSeries
@ -15,6 +17,7 @@ import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.data.domain.Pageable
import java.net.URL
import java.time.LocalDateTime
@ -159,11 +162,11 @@ class BookDaoTest(
bookDao.insert(makeBook("2", libraryId = library.id, seriesId = series.id))
val search =
BookSearch(
libraryIds = listOf(library.id),
seriesIds = listOf(series.id),
SearchCondition.AllOfBook(
SearchCondition.LibraryId(SearchOperator.Is(library.id)),
SearchCondition.SeriesId(SearchOperator.Is(series.id)),
)
val found = bookDao.findAll(search)
val found = bookDao.findAll(search, SearchContext.empty(), Pageable.unpaged()).content
assertThat(found).hasSize(2)
}

View file

@ -7,11 +7,14 @@ import io.mockk.just
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatCode
import org.gotson.komga.domain.model.Author
import org.gotson.komga.domain.model.BookSearchWithReadProgress
import org.gotson.komga.domain.model.BookSearch
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.ReadProgress
import org.gotson.komga.domain.model.ReadStatus
import org.gotson.komga.domain.model.SearchCondition
import org.gotson.komga.domain.model.SearchContext
import org.gotson.komga.domain.model.SearchOperator
import org.gotson.komga.domain.model.makeBook
import org.gotson.komga.domain.model.makeLibrary
import org.gotson.komga.domain.model.makeSeries
@ -133,8 +136,13 @@ class BookDtoDaoTest(
// when
val page =
bookDtoDao.findAll(
BookSearchWithReadProgress(tags = setOf("tag1", "tag2")),
user.id,
BookSearch(
SearchCondition.AnyOfBook(
SearchCondition.Tag(SearchOperator.Is("tag1")),
SearchCondition.Tag(SearchOperator.Is("tag2")),
),
),
SearchContext(user),
Pageable.unpaged(),
)
@ -167,8 +175,13 @@ class BookDtoDaoTest(
// when
val page =
bookDtoDao.findAll(
BookSearchWithReadProgress(authors = listOf(Author("Mark", "writer"), Author("Jim", "inker"))),
user.id,
BookSearch(
SearchCondition.AnyOfBook(
SearchCondition.Author(SearchOperator.Is(SearchCondition.AuthorMatch("Mark", "writer"))),
SearchCondition.Author(SearchOperator.Is(SearchCondition.AuthorMatch("Jim", "inker"))),
),
),
SearchContext(user),
Pageable.unpaged(),
)
@ -189,8 +202,8 @@ class BookDtoDaoTest(
// when
val found =
bookDtoDao.findAll(
BookSearchWithReadProgress(readStatus = listOf(ReadStatus.READ)),
user.id,
BookSearch(SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.READ))),
SearchContext(user),
PageRequest.of(0, 20),
)
@ -208,8 +221,8 @@ class BookDtoDaoTest(
// when
val found =
bookDtoDao.findAll(
BookSearchWithReadProgress(readStatus = listOf(ReadStatus.UNREAD)),
user.id,
BookSearch(SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.UNREAD))),
SearchContext(user),
PageRequest.of(0, 20),
)
@ -227,8 +240,8 @@ class BookDtoDaoTest(
// when
val found =
bookDtoDao.findAll(
BookSearchWithReadProgress(readStatus = listOf(ReadStatus.IN_PROGRESS)),
user.id,
BookSearch(SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.IN_PROGRESS))),
SearchContext(user),
PageRequest.of(0, 20),
)
@ -246,8 +259,13 @@ class BookDtoDaoTest(
// when
val found =
bookDtoDao.findAll(
BookSearchWithReadProgress(readStatus = listOf(ReadStatus.READ, ReadStatus.UNREAD)),
user.id,
BookSearch(
SearchCondition.AnyOfBook(
SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.READ)),
SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.UNREAD)),
),
),
SearchContext(user),
PageRequest.of(0, 20),
)
@ -264,8 +282,13 @@ class BookDtoDaoTest(
// when
val found =
bookDtoDao.findAll(
BookSearchWithReadProgress(readStatus = listOf(ReadStatus.READ, ReadStatus.IN_PROGRESS)),
user.id,
BookSearch(
SearchCondition.AnyOfBook(
SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.READ)),
SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.IN_PROGRESS)),
),
),
SearchContext(user),
PageRequest.of(0, 20),
)
@ -282,8 +305,13 @@ class BookDtoDaoTest(
// when
val found =
bookDtoDao.findAll(
BookSearchWithReadProgress(readStatus = listOf(ReadStatus.UNREAD, ReadStatus.IN_PROGRESS)),
user.id,
BookSearch(
SearchCondition.AnyOfBook(
SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.UNREAD)),
SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.IN_PROGRESS)),
),
),
SearchContext(user),
PageRequest.of(0, 20),
)
@ -300,8 +328,14 @@ class BookDtoDaoTest(
// when
val found =
bookDtoDao.findAll(
BookSearchWithReadProgress(readStatus = listOf(ReadStatus.UNREAD, ReadStatus.IN_PROGRESS, ReadStatus.READ)),
user.id,
BookSearch(
SearchCondition.AnyOfBook(
SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.READ)),
SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.IN_PROGRESS)),
SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.UNREAD)),
),
),
SearchContext(user),
PageRequest.of(0, 20),
)
@ -318,8 +352,8 @@ class BookDtoDaoTest(
// when
val found =
bookDtoDao.findAll(
BookSearchWithReadProgress(),
user.id,
BookSearch(),
SearchContext(user),
PageRequest.of(0, 20),
)
@ -418,8 +452,8 @@ class BookDtoDaoTest(
// when
val found =
bookDtoDao.findAll(
BookSearchWithReadProgress(searchTerm = "batman"),
user.id,
BookSearch(fullTextSearch = "batman"),
SearchContext(user),
UnpagedSorted(Sort.by("relevance")),
).content
@ -446,28 +480,28 @@ class BookDtoDaoTest(
// when
val found =
bookDtoDao.findAll(
BookSearchWithReadProgress(searchTerm = "book"),
user.id,
BookSearch(fullTextSearch = "book"),
SearchContext(user),
UnpagedSorted(Sort.by("name")),
).content
val pages =
(0..2).map {
bookDtoDao.findAll(
BookSearchWithReadProgress(searchTerm = "book"),
user.id,
BookSearch(fullTextSearch = "book"),
SearchContext(user),
PageRequest.of(it, 1, Sort.by("name")),
)
}
val page0 =
bookDtoDao.findAll(
BookSearchWithReadProgress(searchTerm = "book"),
user.id,
BookSearch(fullTextSearch = "book"),
SearchContext(user),
PageRequest.of(0, 2, Sort.by("name")),
)
val page1 =
bookDtoDao.findAll(
BookSearchWithReadProgress(searchTerm = "book"),
user.id,
BookSearch(fullTextSearch = "book"),
SearchContext(user),
PageRequest.of(1, 2, Sort.by("name")),
)
@ -511,8 +545,8 @@ class BookDtoDaoTest(
// when
val found =
bookDtoDao.findAll(
BookSearchWithReadProgress(searchTerm = "eric"),
user.id,
BookSearch(fullTextSearch = "eric"),
SearchContext(user),
UnpagedSorted(Sort.by("relevance")),
).content
@ -545,8 +579,8 @@ class BookDtoDaoTest(
// when
val found =
bookDtoDao.findAll(
BookSearchWithReadProgress(searchTerm = "9782413016878"),
user.id,
BookSearch(fullTextSearch = "9782413016878"),
SearchContext(user),
UnpagedSorted(Sort.by("relevance")),
).content
@ -576,8 +610,8 @@ class BookDtoDaoTest(
// when
val found =
bookDtoDao.findAll(
BookSearchWithReadProgress(searchTerm = "tag:tag1"),
user.id,
BookSearch(fullTextSearch = "tag:tag1"),
SearchContext(user),
UnpagedSorted(Sort.by("relevance")),
).content
@ -607,20 +641,20 @@ class BookDtoDaoTest(
// when
val foundGeneric =
bookDtoDao.findAll(
BookSearchWithReadProgress(searchTerm = "author:bob"),
user.id,
BookSearch(fullTextSearch = "author:bob"),
SearchContext(user),
UnpagedSorted(Sort.by("relevance")),
).content
val foundByRole =
bookDtoDao.findAll(
BookSearchWithReadProgress(searchTerm = "writer:bob"),
user.id,
BookSearch(fullTextSearch = "writer:bob"),
SearchContext(user),
UnpagedSorted(Sort.by("relevance")),
).content
val notFound =
bookDtoDao.findAll(
BookSearchWithReadProgress(searchTerm = "penciller:bob"),
user.id,
BookSearch(fullTextSearch = "penciller:bob"),
SearchContext(user),
UnpagedSorted(Sort.by("relevance")),
).content
@ -648,8 +682,8 @@ class BookDtoDaoTest(
// when
val found =
bookDtoDao.findAll(
BookSearchWithReadProgress(searchTerm = "release_date:1999"),
user.id,
BookSearch(fullTextSearch = "release_date:1999"),
SearchContext(user),
UnpagedSorted(Sort.by("relevance")),
).content
@ -678,8 +712,8 @@ class BookDtoDaoTest(
// when
val found =
bookDtoDao.findAll(
BookSearchWithReadProgress(searchTerm = "release_date:[1990 TO 2010]"),
user.id,
BookSearch(fullTextSearch = "release_date:[1990 TO 2010]"),
SearchContext(user),
UnpagedSorted(Sort.by("relevance")),
).content
@ -704,8 +738,8 @@ class BookDtoDaoTest(
// when
val found =
bookDtoDao.findAll(
BookSearchWithReadProgress(searchTerm = "status:error"),
user.id,
BookSearch(fullTextSearch = "status:error"),
SearchContext(user),
UnpagedSorted(Sort.by("relevance")),
).content
@ -734,8 +768,8 @@ class BookDtoDaoTest(
// when
val found =
bookDtoDao.findAll(
BookSearchWithReadProgress(searchTerm = "deleted:true"),
user.id,
BookSearch(fullTextSearch = "deleted:true"),
SearchContext(user),
UnpagedSorted(Sort.by("relevance")),
).content
@ -761,8 +795,8 @@ class BookDtoDaoTest(
// when
val found =
bookDtoDao.findAll(
BookSearchWithReadProgress(searchTerm = "s.w.o.r.d."),
user.id,
BookSearch(fullTextSearch = "s.w.o.r.d"),
SearchContext(user),
UnpagedSorted(Sort.by("relevance")),
).content
@ -790,8 +824,8 @@ class BookDtoDaoTest(
// when
val found =
bookDtoDao.findAll(
BookSearchWithReadProgress(searchTerm = "batman robin"),
user.id,
BookSearch(fullTextSearch = "batman robin"),
SearchContext(user),
UnpagedSorted(Sort.by("relevance")),
).content
@ -818,8 +852,8 @@ class BookDtoDaoTest(
// when
val found =
bookDtoDao.findAll(
BookSearchWithReadProgress(searchTerm = "x-men"),
user.id,
BookSearch(fullTextSearch = "x-men"),
SearchContext(user),
UnpagedSorted(Sort.by("relevance")),
).content
@ -834,8 +868,8 @@ class BookDtoDaoTest(
// when
val found =
bookDtoDao.findAll(
BookSearchWithReadProgress(searchTerm = "publisher:batman"),
user.id,
BookSearch(fullTextSearch = "publisher:batman"),
SearchContext(user),
UnpagedSorted(Sort.by("relevance")),
).content
@ -860,8 +894,8 @@ class BookDtoDaoTest(
// when
val found =
bookDtoDao.findAll(
BookSearchWithReadProgress(searchTerm = "不道德"),
user.id,
BookSearch(fullTextSearch = "不道德"),
SearchContext(user),
UnpagedSorted(Sort.by("relevance")),
).content

View file

@ -0,0 +1,923 @@
package org.gotson.komga.infrastructure.jooq.main
import com.ninjasquad.springmockk.MockkBean
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.catchThrowable
import org.gotson.komga.domain.model.Author
import org.gotson.komga.domain.model.BookSearch
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.MediaProfile
import org.gotson.komga.domain.model.MediaType
import org.gotson.komga.domain.model.ReadList
import org.gotson.komga.domain.model.ReadProgress
import org.gotson.komga.domain.model.ReadStatus
import org.gotson.komga.domain.model.SearchCondition
import org.gotson.komga.domain.model.SearchCondition.AuthorMatch
import org.gotson.komga.domain.model.SearchContext
import org.gotson.komga.domain.model.SearchOperator
import org.gotson.komga.domain.model.makeBook
import org.gotson.komga.domain.model.makeLibrary
import org.gotson.komga.domain.model.makeSeries
import org.gotson.komga.domain.persistence.BookMetadataRepository
import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.KomgaUserRepository
import org.gotson.komga.domain.persistence.LibraryRepository
import org.gotson.komga.domain.persistence.MediaRepository
import org.gotson.komga.domain.persistence.ReadListRepository
import org.gotson.komga.domain.persistence.ReadProgressRepository
import org.gotson.komga.domain.service.BookLifecycle
import org.gotson.komga.domain.service.KomgaUserLifecycle
import org.gotson.komga.domain.service.LibraryLifecycle
import org.gotson.komga.domain.service.SeriesLifecycle
import org.gotson.komga.infrastructure.jooq.UnpagedSorted
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import java.time.Duration
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZonedDateTime
@SpringBootTest
class BookSearchTest(
@Autowired private val bookDao: BookDao,
@Autowired private val bookDtoDao: BookDtoDao,
@Autowired private val libraryRepository: LibraryRepository,
@Autowired private val bookMetadataRepository: BookMetadataRepository,
@Autowired private val mediaRepository: MediaRepository,
@Autowired private val readProgressRepository: ReadProgressRepository,
@Autowired private val userRepository: KomgaUserRepository,
@Autowired private val bookRepository: BookRepository,
@Autowired private val bookLifecycle: BookLifecycle,
@Autowired private val seriesLifecycle: SeriesLifecycle,
@Autowired private val libraryLifecycle: LibraryLifecycle,
@Autowired private val userLifecycle: KomgaUserLifecycle,
@Autowired private val readListRepository: ReadListRepository,
) {
private val library1 = makeLibrary()
private val library2 = makeLibrary()
private val series1 = makeSeries("Series 1").copy(libraryId = library1.id)
private val series2 = makeSeries("Series 2").copy(libraryId = library2.id)
private val user1 = KomgaUser("user1@example.org", "p", false)
private val user2 = KomgaUser("user2@example.org", "p", false)
@MockkBean
private lateinit var mockEventPublisher: ApplicationEventPublisher
@BeforeAll
fun setup() {
every { mockEventPublisher.publishEvent(any()) } just Runs
libraryRepository.insert(library1)
libraryRepository.insert(library2)
seriesLifecycle.createSeries(series1)
seriesLifecycle.createSeries(series2)
userRepository.insert(user1)
userRepository.insert(user2)
}
@BeforeEach
fun resetMocks() {
every { mockEventPublisher.publishEvent(any()) } just Runs
}
@AfterEach
fun deleteBooks() {
readListRepository.deleteAll()
bookLifecycle.deleteMany(bookRepository.findAll())
assertThat(bookDao.count()).isEqualTo(0)
}
@AfterAll
fun tearDown() {
every { mockEventPublisher.publishEvent(any()) } just Runs
userRepository.findAll().forEach {
userLifecycle.deleteUser(it)
}
libraryRepository.findAll().forEach {
libraryLifecycle.deleteLibrary(it)
}
}
@Test
fun `given some books when searching by library then results are accurate`() {
val book1 = makeBook("1", libraryId = library1.id, seriesId = series1.id)
val book2 = makeBook("2", libraryId = library2.id, seriesId = series2.id)
seriesLifecycle.addBooks(series1, listOf(book1))
seriesLifecycle.addBooks(series2, listOf(book2))
run {
val search = BookSearch(SearchCondition.LibraryId(SearchOperator.Is(library1.id)))
val found = bookDao.findAll(search.condition, SearchContext(user1), UnpagedSorted(Sort.by("readList.number"))).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), UnpagedSorted(Sort.by("readList.number"))).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("1")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1")
}
run {
val search = BookSearch(SearchCondition.LibraryId(SearchOperator.IsNot(library1.id)))
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("2")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2")
}
}
@Test
fun `given some books when searching by series then results are accurate`() {
val book1 = makeBook("1", libraryId = library1.id, seriesId = series1.id)
val book2 = makeBook("2", libraryId = library2.id, seriesId = series2.id)
seriesLifecycle.addBooks(series1, listOf(book1))
seriesLifecycle.addBooks(series2, listOf(book2))
run {
val search = BookSearch(SearchCondition.SeriesId(SearchOperator.Is(series1.id)))
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("1")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1")
}
run {
val search = BookSearch(SearchCondition.SeriesId(SearchOperator.IsNot(series1.id)))
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("2")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2")
}
}
@Test
fun `given some books when searching by read list then results are accurate`() {
val book1 = makeBook("1", libraryId = library1.id, seriesId = series1.id)
val book2 = makeBook("2", libraryId = library2.id, seriesId = series2.id)
seriesLifecycle.addBooks(series1, listOf(book1))
seriesLifecycle.addBooks(series2, listOf(book2))
val readList = ReadList("rl1", bookIds = mapOf(1 to book1.id).toSortedMap())
readListRepository.insert(readList)
run {
val search = BookSearch(SearchCondition.ReadListId(SearchOperator.Is(readList.id)))
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("1")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1")
}
run {
val search = BookSearch(SearchCondition.ReadListId(SearchOperator.IsNot(readList.id)))
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("2")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2")
}
}
@Test
fun `given some books when searching by deleted then results are accurate`() {
val book1 = makeBook("1", libraryId = library1.id, seriesId = series1.id).copy(deletedDate = LocalDateTime.now())
val book2 = makeBook("2", libraryId = library2.id, seriesId = series2.id)
seriesLifecycle.addBooks(series1, listOf(book1))
seriesLifecycle.addBooks(series2, listOf(book2))
run {
val search = BookSearch(SearchCondition.Deleted(SearchOperator.IsTrue))
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("1")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1")
}
run {
val search = BookSearch(SearchCondition.Deleted(SearchOperator.IsFalse))
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("2")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2")
}
}
@Test
fun `given some books when searching by title then results are accurate`() {
makeBook("1", libraryId = library1.id, seriesId = series1.id).let { book ->
seriesLifecycle.addBooks(series1, listOf(book))
bookMetadataRepository.findById(book.id).let {
bookMetadataRepository.update(it.copy(title = "Book 1"))
}
}
makeBook("2", libraryId = library2.id, seriesId = series2.id).let { book ->
seriesLifecycle.addBooks(series2, listOf(book))
bookMetadataRepository.findById(book.id).let {
bookMetadataRepository.update(it.copy(title = "Book 2"))
}
}
run {
val search = BookSearch(SearchCondition.Title(SearchOperator.Is("boOK 1")))
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("1")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1")
}
run {
val search = BookSearch(SearchCondition.Title(SearchOperator.IsNot("book 1")))
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("2")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2")
}
run {
val search = BookSearch(SearchCondition.Title(SearchOperator.Contains("book")))
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2")
}
run {
val search = BookSearch(SearchCondition.Title(SearchOperator.DoesNotContain("1")))
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("2")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2")
}
run {
val search = BookSearch(SearchCondition.Title(SearchOperator.BeginsWith("book")))
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2")
}
run {
val search = BookSearch(SearchCondition.Title(SearchOperator.EndsWith("1")))
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("1")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1")
}
}
@Test
fun `given some books when searching by release date then results are accurate`() {
makeBook("1", libraryId = library1.id, seriesId = series1.id).let { book ->
seriesLifecycle.addBooks(series1, listOf(book))
bookMetadataRepository.findById(book.id).let {
bookMetadataRepository.update(it.copy(releaseDate = LocalDate.now().minusDays(5)))
}
}
makeBook("2", libraryId = library2.id, seriesId = series2.id).let { book ->
seriesLifecycle.addBooks(series2, listOf(book))
}
run {
val search = BookSearch(SearchCondition.ReleaseDate(SearchOperator.After(ZonedDateTime.now().minusDays(10))))
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("1")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1")
}
run {
val search = BookSearch(SearchCondition.ReleaseDate(SearchOperator.Before(ZonedDateTime.now())))
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("1")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1")
}
run {
val search = BookSearch(SearchCondition.ReleaseDate(SearchOperator.IsInTheLast(Duration.ofDays(10))))
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("1")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1")
}
run {
val search = BookSearch(SearchCondition.ReleaseDate(SearchOperator.IsNotInTheLast(Duration.ofDays(10))))
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found).isEmpty()
assertThat(foundDto).isEmpty()
}
run {
val search = BookSearch(SearchCondition.ReleaseDate(SearchOperator.IsNotInTheLast(Duration.ofDays(1))))
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("1")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1")
}
run {
val search = BookSearch(SearchCondition.ReleaseDate(SearchOperator.IsNull))
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("2")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2")
}
run {
val search = BookSearch(SearchCondition.ReleaseDate(SearchOperator.IsNotNull))
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("1")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1")
}
}
@Test
fun `given some books when searching by number sort then results are accurate`() {
makeBook("1", libraryId = library1.id, seriesId = series1.id).let { book ->
seriesLifecycle.addBooks(series1, listOf(book))
bookMetadataRepository.findById(book.id).let {
bookMetadataRepository.update(it.copy(numberSort = 1F))
}
}
makeBook("2", libraryId = library2.id, seriesId = series2.id).let { book ->
seriesLifecycle.addBooks(series2, listOf(book))
bookMetadataRepository.findById(book.id).let {
bookMetadataRepository.update(it.copy(numberSort = 10.5F))
}
}
run {
val search = BookSearch(SearchCondition.NumberSort(SearchOperator.Is(10.5F)))
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("2")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2")
}
run {
val search = BookSearch(SearchCondition.NumberSort(SearchOperator.IsNot(10.5F)))
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("1")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1")
}
run {
val search = BookSearch(SearchCondition.NumberSort(SearchOperator.GreaterThan(0F)))
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2")
}
run {
val search = BookSearch(SearchCondition.NumberSort(SearchOperator.GreaterThan(5F)))
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("2")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2")
}
run {
val search = BookSearch(SearchCondition.NumberSort(SearchOperator.LessThan(11F)))
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2")
}
run {
val search =
BookSearch(
SearchCondition.AnyOfBook(
listOf(
SearchCondition.NumberSort(SearchOperator.Is(10.5F)),
SearchCondition.NumberSort(SearchOperator.Is(1F)),
),
),
)
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2")
}
run {
val search =
BookSearch(
SearchCondition.AllOfBook(
listOf(
SearchCondition.NumberSort(SearchOperator.Is(10.5F)),
SearchCondition.NumberSort(SearchOperator.Is(1F)),
),
),
)
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found).isEmpty()
assertThat(foundDto).isEmpty()
}
}
@Test
fun `given some books when searching by tag then results are accurate`() {
makeBook("1", libraryId = library1.id, seriesId = series1.id).let { book ->
seriesLifecycle.addBooks(series1, listOf(book))
bookMetadataRepository.findById(book.id).let {
bookMetadataRepository.update(it.copy(tags = setOf("fiction", "horror")))
}
}
makeBook("2", libraryId = library2.id, seriesId = series2.id).let { book ->
seriesLifecycle.addBooks(series2, listOf(book))
bookMetadataRepository.findById(book.id).let {
bookMetadataRepository.update(it.copy(tags = setOf("fiction")))
}
}
makeBook("3", libraryId = library2.id, seriesId = series2.id).let { book ->
seriesLifecycle.addBooks(series2, listOf(book))
bookMetadataRepository.findById(book.id).let {
bookMetadataRepository.update(it.copy(tags = setOf("fantasy")))
}
}
makeBook("4", libraryId = library2.id, seriesId = series2.id).let { book ->
seriesLifecycle.addBooks(series2, listOf(book))
}
run {
val search = BookSearch(SearchCondition.Tag(SearchOperator.Is("FICTION")))
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2")
}
run {
val search =
BookSearch(
SearchCondition.AllOfBook(
listOf(
SearchCondition.Tag(SearchOperator.Is("FICTION")),
SearchCondition.Tag(SearchOperator.Is("horror")),
),
),
)
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("1")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1")
}
run {
val search =
BookSearch(
SearchCondition.AnyOfBook(
listOf(
SearchCondition.Tag(SearchOperator.Is("horror")),
SearchCondition.Tag(SearchOperator.Is("notexist")),
SearchCondition.Tag(SearchOperator.Is("fantasy")),
),
),
)
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "3")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "3")
}
run {
val search =
BookSearch(
SearchCondition.AllOfBook(
listOf(
SearchCondition.Tag(SearchOperator.Is("fiction")),
SearchCondition.Tag(SearchOperator.IsNot("horror")),
),
),
)
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("2")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2")
}
run {
val search = BookSearch(SearchCondition.Tag(SearchOperator.IsNot("fiction")))
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("3", "4")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("3", "4")
}
}
@Test
fun `given some books when searching by media status then results are accurate`() {
makeBook("1", libraryId = library1.id, seriesId = series1.id).let { book ->
seriesLifecycle.addBooks(series1, listOf(book))
mediaRepository.findById(book.id).let {
mediaRepository.update(it.copy(status = Media.Status.READY))
}
}
makeBook("2", libraryId = library2.id, seriesId = series2.id).let { book ->
seriesLifecycle.addBooks(series2, listOf(book))
mediaRepository.findById(book.id).let {
mediaRepository.update(it.copy(status = Media.Status.ERROR))
}
}
run {
val search = BookSearch(SearchCondition.MediaStatus(SearchOperator.Is(Media.Status.READY)))
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("1")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1")
}
run {
val search = BookSearch(SearchCondition.MediaStatus(SearchOperator.IsNot(Media.Status.READY)))
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("2")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2")
}
run {
val search =
BookSearch(
SearchCondition.AllOfBook(
listOf(
SearchCondition.MediaStatus(SearchOperator.Is(Media.Status.READY)),
SearchCondition.MediaStatus(SearchOperator.Is(Media.Status.ERROR)),
),
),
)
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found).isEmpty()
assertThat(foundDto).isEmpty()
}
run {
val search =
BookSearch(
SearchCondition.AnyOfBook(
listOf(
SearchCondition.MediaStatus(SearchOperator.Is(Media.Status.READY)),
SearchCondition.MediaStatus(SearchOperator.Is(Media.Status.ERROR)),
),
),
)
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2")
}
}
@Test
fun `given some books when searching by media profile then results are accurate`() {
makeBook("1", libraryId = library1.id, seriesId = series1.id).let { book ->
seriesLifecycle.addBooks(series1, listOf(book))
mediaRepository.findById(book.id).let {
mediaRepository.update(it.copy(mediaType = MediaType.ZIP.type))
}
}
makeBook("2", libraryId = library2.id, seriesId = series2.id).let { book ->
seriesLifecycle.addBooks(series2, listOf(book))
mediaRepository.findById(book.id).let {
mediaRepository.update(it.copy(mediaType = MediaType.RAR_4.type))
}
}
makeBook("3", libraryId = library2.id, seriesId = series2.id).let { book ->
seriesLifecycle.addBooks(series2, listOf(book))
mediaRepository.findById(book.id).let {
mediaRepository.update(it.copy(mediaType = MediaType.EPUB.type))
}
}
run {
val search = BookSearch(SearchCondition.MediaProfile(SearchOperator.Is(MediaProfile.DIVINA)))
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2")
}
run {
val search = BookSearch(SearchCondition.MediaProfile(SearchOperator.IsNot(MediaProfile.EPUB)))
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2")
}
run {
val search = BookSearch(SearchCondition.MediaProfile(SearchOperator.Is(MediaProfile.EPUB)))
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("3")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("3")
}
run {
val search =
BookSearch(
SearchCondition.AllOfBook(
listOf(
SearchCondition.MediaProfile(SearchOperator.Is(MediaProfile.DIVINA)),
SearchCondition.MediaProfile(SearchOperator.Is(MediaProfile.EPUB)),
),
),
)
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found).isEmpty()
assertThat(foundDto).isEmpty()
}
run {
val search =
BookSearch(
SearchCondition.AnyOfBook(
listOf(
SearchCondition.MediaProfile(SearchOperator.Is(MediaProfile.DIVINA)),
SearchCondition.MediaProfile(SearchOperator.Is(MediaProfile.EPUB)),
),
),
)
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2", "3")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2", "3")
}
}
@Test
fun `given some books when searching by read progress then results are accurate`() {
makeBook("1", libraryId = library1.id, seriesId = series1.id).let { book ->
seriesLifecycle.addBooks(series1, listOf(book))
readProgressRepository.save(ReadProgress(book.id, user1.id, 5, false))
}
makeBook("2", libraryId = library2.id, seriesId = series2.id).let { book ->
seriesLifecycle.addBooks(series2, listOf(book))
readProgressRepository.save(ReadProgress(book.id, user1.id, 10, true))
}
makeBook("3", libraryId = library2.id, seriesId = series2.id).let { book ->
seriesLifecycle.addBooks(series2, listOf(book))
}
run {
val search = BookSearch(SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.IN_PROGRESS)))
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("1")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1")
}
run {
val search = BookSearch(SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.UNREAD)))
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("3")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("3")
}
run {
val search = BookSearch(SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.READ)))
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("2")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2")
}
run {
val search = BookSearch(SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.READ)))
val found = bookDao.findAll(search.condition, SearchContext(user2), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user2), Pageable.unpaged()).content
assertThat(found).isEmpty()
assertThat(foundDto).isEmpty()
}
run {
val search = BookSearch(SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.UNREAD)))
val found = bookDao.findAll(search.condition, SearchContext(user2), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user2), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2", "3")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2", "3")
}
run {
val search = BookSearch(SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.READ)))
val found = bookDao.findAll(search.condition, SearchContext(null), Pageable.unpaged()).content
val thrown = catchThrowable { bookDtoDao.findAll(search, SearchContext(null), Pageable.unpaged()) }
assertThat(found).isEmpty()
assertThat(thrown).isInstanceOf(IllegalArgumentException::class.java)
}
}
@Test
fun `given some books when searching by author then results are accurate`() {
makeBook("1", libraryId = library1.id, seriesId = series1.id).let { book ->
seriesLifecycle.addBooks(series1, listOf(book))
bookMetadataRepository.findById(book.id).let {
bookMetadataRepository.update(it.copy(authors = listOf(Author("john", "writer"), Author("jim", "cover"))))
}
}
makeBook("2", libraryId = library2.id, seriesId = series2.id).let { book ->
seriesLifecycle.addBooks(series2, listOf(book))
bookMetadataRepository.findById(book.id).let {
bookMetadataRepository.update(it.copy(authors = listOf(Author("john", "artist"), Author("amanda", "artist"))))
}
}
makeBook("3", libraryId = library2.id, seriesId = series2.id).let { book ->
seriesLifecycle.addBooks(series2, listOf(book))
bookMetadataRepository.findById(book.id).let {
bookMetadataRepository.update(it.copy(authors = listOf(Author("jack", "writer"))))
}
}
makeBook("4", libraryId = library2.id, seriesId = series2.id).let { book ->
seriesLifecycle.addBooks(series2, listOf(book))
}
// books with an author named 'john'
run {
val search =
BookSearch(
SearchCondition.Author(SearchOperator.Is(AuthorMatch("john"))),
)
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2")
}
// books with an author named 'john' with the 'writer' role
run {
val search =
BookSearch(
SearchCondition.Author(SearchOperator.Is(AuthorMatch("john", "writer"))),
)
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("1")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1")
}
// books with any author with the 'writer' role
run {
val search =
BookSearch(
SearchCondition.Author(SearchOperator.Is(AuthorMatch(role = "writer"))),
)
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "3")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "3")
}
// books without any author named 'john' with a 'writer' role
run {
val search =
BookSearch(
SearchCondition.Author(SearchOperator.IsNot(AuthorMatch("john", "writer"))),
)
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("2", "3", "4")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2", "3", "4")
}
// books without any author named 'john'
run {
val search =
BookSearch(
SearchCondition.Author(SearchOperator.IsNot(AuthorMatch("jOhn"))),
)
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("3", "4")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("3", "4")
}
// books without any author with the 'writer' role
run {
val search =
BookSearch(
SearchCondition.Author(SearchOperator.IsNot(AuthorMatch(role = "writer"))),
)
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("2", "4")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2", "4")
}
// empty AuthorMatch does not apply any condition
run {
val search =
BookSearch(
SearchCondition.Author(SearchOperator.Is(AuthorMatch())),
)
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2", "3", "4")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1", "2", "3", "4")
}
}
@Test
fun `given some books when searching by one-shot then results are accurate`() {
makeBook("1", libraryId = library1.id, seriesId = series1.id).copy(oneshot = true).let { book ->
seriesLifecycle.addBooks(series1, listOf(book))
}
makeBook("2", libraryId = library2.id, seriesId = series2.id).let { book ->
seriesLifecycle.addBooks(series2, listOf(book))
}
run {
val search =
BookSearch(
SearchCondition.OneShot(SearchOperator.IsTrue),
)
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("1")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("1")
}
run {
val search =
BookSearch(
SearchCondition.OneShot(SearchOperator.IsFalse),
)
val found = bookDao.findAll(search.condition, SearchContext(user1), Pageable.unpaged()).content
val foundDto = bookDtoDao.findAll(search, SearchContext(user1), Pageable.unpaged()).content
assertThat(found.map { it.name }).containsExactlyInAnyOrder("2")
assertThat(foundDto.map { it.name }).containsExactlyInAnyOrder("2")
}
}
}

View file

@ -2,7 +2,6 @@ package org.gotson.komga.infrastructure.jooq.main
import org.assertj.core.api.Assertions.assertThat
import org.gotson.komga.domain.model.Series
import org.gotson.komga.domain.model.SeriesSearch
import org.gotson.komga.domain.model.makeLibrary
import org.gotson.komga.domain.persistence.LibraryRepository
import org.gotson.komga.infrastructure.jooq.offset
@ -200,43 +199,6 @@ class SeriesDaoTest(
assertThat(found).isNull()
}
@Test
fun `given existing series when searching then result is returned`() {
val series =
Series(
name = "Series",
url = URL("file://series"),
fileLastModified = LocalDateTime.now(),
libraryId = library.id,
)
seriesDao.insert(series)
val search =
SeriesSearch(
libraryIds = listOf(library.id),
)
val found = seriesDao.findAll(search)
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 =

View file

@ -9,8 +9,11 @@ import org.gotson.komga.domain.model.Author
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.ReadProgress
import org.gotson.komga.domain.model.ReadStatus
import org.gotson.komga.domain.model.SearchCondition
import org.gotson.komga.domain.model.SearchContext
import org.gotson.komga.domain.model.SearchOperator
import org.gotson.komga.domain.model.SeriesMetadata
import org.gotson.komga.domain.model.SeriesSearchWithReadProgress
import org.gotson.komga.domain.model.SeriesSearch
import org.gotson.komga.domain.model.makeBook
import org.gotson.komga.domain.model.makeLibrary
import org.gotson.komga.domain.model.makeSeries
@ -133,8 +136,8 @@ class SeriesDtoDaoTest(
// when
val found =
seriesDtoDao.findAll(
SeriesSearchWithReadProgress(readStatus = listOf(ReadStatus.READ)),
user.id,
SeriesSearch(SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.READ))),
SearchContext(user),
PageRequest.of(0, 20),
).sortedBy { it.name }
@ -153,8 +156,8 @@ class SeriesDtoDaoTest(
// when
val found =
seriesDtoDao.findAll(
SeriesSearchWithReadProgress(readStatus = listOf(ReadStatus.UNREAD)),
user.id,
SeriesSearch(SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.UNREAD))),
SearchContext(user),
PageRequest.of(0, 20),
).sortedBy { it.name }
@ -173,8 +176,8 @@ class SeriesDtoDaoTest(
// when
val found =
seriesDtoDao.findAll(
SeriesSearchWithReadProgress(readStatus = listOf(ReadStatus.IN_PROGRESS)),
user.id,
SeriesSearch(SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.IN_PROGRESS))),
SearchContext(user),
PageRequest.of(0, 20),
).sortedBy { it.name }
@ -196,8 +199,13 @@ class SeriesDtoDaoTest(
// when
val found =
seriesDtoDao.findAll(
SeriesSearchWithReadProgress(readStatus = listOf(ReadStatus.READ, ReadStatus.UNREAD)),
user.id,
SeriesSearch(
SearchCondition.AnyOfSeries(
SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.READ)),
SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.UNREAD)),
),
),
SearchContext(user),
PageRequest.of(0, 20),
).sortedBy { it.name }
@ -214,8 +222,13 @@ class SeriesDtoDaoTest(
// when
val found =
seriesDtoDao.findAll(
SeriesSearchWithReadProgress(readStatus = listOf(ReadStatus.READ, ReadStatus.IN_PROGRESS)),
user.id,
SeriesSearch(
SearchCondition.AnyOfSeries(
SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.READ)),
SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.IN_PROGRESS)),
),
),
SearchContext(user),
PageRequest.of(0, 20),
).sortedBy { it.name }
@ -232,8 +245,13 @@ class SeriesDtoDaoTest(
// when
val found =
seriesDtoDao.findAll(
SeriesSearchWithReadProgress(readStatus = listOf(ReadStatus.UNREAD, ReadStatus.IN_PROGRESS)),
user.id,
SeriesSearch(
SearchCondition.AnyOfSeries(
SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.UNREAD)),
SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.IN_PROGRESS)),
),
),
SearchContext(user),
PageRequest.of(0, 20),
).sortedBy { it.name }
@ -250,8 +268,14 @@ class SeriesDtoDaoTest(
// when
val found =
seriesDtoDao.findAll(
SeriesSearchWithReadProgress(readStatus = listOf(ReadStatus.READ, ReadStatus.IN_PROGRESS, ReadStatus.UNREAD)),
user.id,
SeriesSearch(
SearchCondition.AnyOfSeries(
SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.READ)),
SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.IN_PROGRESS)),
SearchCondition.ReadStatus(SearchOperator.Is(ReadStatus.UNREAD)),
),
),
SearchContext(user),
PageRequest.of(0, 20),
).sortedBy { it.name }
@ -268,8 +292,7 @@ class SeriesDtoDaoTest(
// when
val found =
seriesDtoDao.findAll(
SeriesSearchWithReadProgress(),
user.id,
SearchContext(user),
PageRequest.of(0, 20),
).sortedBy { it.name }
@ -294,8 +317,8 @@ class SeriesDtoDaoTest(
// when
val found =
seriesDtoDao.findAll(
SeriesSearchWithReadProgress(searchTerm = "batman"),
user.id,
SeriesSearch(fullTextSearch = "batman"),
SearchContext(user),
UnpagedSorted(Sort.by("relevance")),
).content
@ -320,8 +343,8 @@ class SeriesDtoDaoTest(
// when
val found =
seriesDtoDao.findAll(
SeriesSearchWithReadProgress(searchTerm = "publisher:vertigo"),
user.id,
SeriesSearch(fullTextSearch = "publisher:vertigo"),
SearchContext(user),
UnpagedSorted(Sort.by("relevance")),
).content
@ -346,8 +369,8 @@ class SeriesDtoDaoTest(
// when
val found =
seriesDtoDao.findAll(
SeriesSearchWithReadProgress(searchTerm = "status:hiatus"),
user.id,
SeriesSearch(fullTextSearch = "status:hiatus"),
SearchContext(user),
UnpagedSorted(Sort.by("relevance")),
).content
@ -372,8 +395,8 @@ class SeriesDtoDaoTest(
// when
val found =
seriesDtoDao.findAll(
SeriesSearchWithReadProgress(searchTerm = "reading_direction:left_to_right"),
user.id,
SeriesSearch(fullTextSearch = "reading_direction:left_to_right"),
SearchContext(user),
UnpagedSorted(Sort.by("relevance")),
).content
@ -398,8 +421,8 @@ class SeriesDtoDaoTest(
// when
val found =
seriesDtoDao.findAll(
SeriesSearchWithReadProgress(searchTerm = "age_rating:12"),
user.id,
SeriesSearch(fullTextSearch = "age_rating:12"),
SearchContext(user),
UnpagedSorted(Sort.by("relevance")),
).content
@ -424,8 +447,8 @@ class SeriesDtoDaoTest(
// when
val found =
seriesDtoDao.findAll(
SeriesSearchWithReadProgress(searchTerm = "language:en-us"),
user.id,
SeriesSearch(fullTextSearch = "language:en-us"),
SearchContext(user),
UnpagedSorted(Sort.by("relevance")),
).content
@ -456,43 +479,43 @@ class SeriesDtoDaoTest(
// when
val foundByBookTag =
seriesDtoDao.findAll(
SeriesSearchWithReadProgress(searchTerm = "book_tag:booktag"),
user.id,
SeriesSearch(fullTextSearch = "book_tag:booktag"),
SearchContext(user),
UnpagedSorted(Sort.by("relevance")),
).content
val notFoundByBookTag =
seriesDtoDao.findAll(
SeriesSearchWithReadProgress(searchTerm = "book_tag:seriestag"),
user.id,
SeriesSearch(fullTextSearch = "book_tag:seriestag"),
SearchContext(user),
UnpagedSorted(Sort.by("relevance")),
).content
val foundBySeriesTag =
seriesDtoDao.findAll(
SeriesSearchWithReadProgress(searchTerm = "series_tag:seriestag"),
user.id,
SeriesSearch(fullTextSearch = "series_tag:seriestag"),
SearchContext(user),
UnpagedSorted(Sort.by("relevance")),
).content
val notFoundBySeriesTag =
seriesDtoDao.findAll(
SeriesSearchWithReadProgress(searchTerm = "series_tag:booktag"),
user.id,
SeriesSearch(fullTextSearch = "series_tag:booktag"),
SearchContext(user),
UnpagedSorted(Sort.by("relevance")),
).content
val foundByTagFromBook =
seriesDtoDao.findAll(
SeriesSearchWithReadProgress(searchTerm = "tag:booktag"),
user.id,
SeriesSearch(fullTextSearch = "tag:booktag"),
SearchContext(user),
UnpagedSorted(Sort.by("relevance")),
).content
val foundByTagFromSeries =
seriesDtoDao.findAll(
SeriesSearchWithReadProgress(searchTerm = "tag:seriestag"),
user.id,
SeriesSearch(fullTextSearch = "tag:seriestag"),
SearchContext(user),
UnpagedSorted(Sort.by("relevance")),
).content
@ -530,8 +553,8 @@ class SeriesDtoDaoTest(
// when
val found =
seriesDtoDao.findAll(
SeriesSearchWithReadProgress(searchTerm = "genre:action"),
user.id,
SeriesSearch(fullTextSearch = "genre:action"),
SearchContext(user),
UnpagedSorted(Sort.by("relevance")),
).content
@ -556,8 +579,8 @@ class SeriesDtoDaoTest(
// when
val found =
seriesDtoDao.findAll(
SeriesSearchWithReadProgress(searchTerm = "total_book_count:5"),
user.id,
SeriesSearch(fullTextSearch = "total_book_count:5"),
SearchContext(user),
UnpagedSorted(Sort.by("relevance")),
).content
@ -590,8 +613,8 @@ class SeriesDtoDaoTest(
// when
val found =
seriesDtoDao.findAll(
SeriesSearchWithReadProgress(searchTerm = "book_count:2"),
user.id,
SeriesSearch(fullTextSearch = "book_count:2"),
SearchContext(user),
UnpagedSorted(Sort.by("relevance")),
).content
@ -626,22 +649,22 @@ class SeriesDtoDaoTest(
// when
val foundGeneric =
seriesDtoDao.findAll(
SeriesSearchWithReadProgress(searchTerm = "author:david"),
user.id,
SeriesSearch(fullTextSearch = "author:david"),
SearchContext(user),
UnpagedSorted(Sort.by("relevance")),
).content
val foundByRole =
seriesDtoDao.findAll(
SeriesSearchWithReadProgress(searchTerm = "penciller:david"),
user.id,
SeriesSearch(fullTextSearch = "penciller:david"),
SearchContext(user),
UnpagedSorted(Sort.by("relevance")),
).content
val notFoundByRole =
seriesDtoDao.findAll(
SeriesSearchWithReadProgress(searchTerm = "writer:david"),
user.id,
SeriesSearch(fullTextSearch = "writer:david"),
SearchContext(user),
UnpagedSorted(Sort.by("relevance")),
).content
@ -674,8 +697,8 @@ class SeriesDtoDaoTest(
// when
val found =
seriesDtoDao.findAll(
SeriesSearchWithReadProgress(searchTerm = "release_date:1999"),
user.id,
SeriesSearch(fullTextSearch = "release_date:1999"),
SearchContext(user),
UnpagedSorted(Sort.by("relevance")),
).content
@ -696,8 +719,8 @@ class SeriesDtoDaoTest(
// when
val found =
seriesDtoDao.findAll(
SeriesSearchWithReadProgress(searchTerm = "deleted:true"),
user.id,
SeriesSearch(fullTextSearch = "deleted:true"),
SearchContext(user),
UnpagedSorted(Sort.by("relevance")),
).content

View file

@ -82,12 +82,11 @@ class KoboControllerTest(
val book1 = makeBook("valid", libraryId = library1.id)
val book2 = makeBook("valid", libraryId = library1.id)
val series =
makeSeries(name = "series1", libraryId = library1.id).also { series ->
seriesLifecycle.createSeries(series).let { created ->
seriesLifecycle.addBooks(created, listOf(book1, book2))
}
makeSeries(name = "series1", libraryId = library1.id).also { series ->
seriesLifecycle.createSeries(series).let { created ->
seriesLifecycle.addBooks(created, listOf(book1, book2))
}
}
mediaRepository.findById(book1.id).let { media -> mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = MediaType.EPUB.type)) }
mediaRepository.findById(book2.id).let { media -> mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = MediaType.EPUB.type)) }

View file

@ -96,6 +96,7 @@ class SeriesCollectionControllerTest(
SeriesCollection(
name = "Lib1+2",
seriesIds = (seriesLibrary1 + seriesLibrary2).map { it.id },
ordered = true,
),
)
}
@ -142,6 +143,12 @@ class SeriesCollectionControllerTest(
jsonPath("$.seriesIds.length()") { value(10) }
jsonPath("$.filtered") { value(false) }
}
mockMvc.get("/api/v1/collections/${colLibBoth.id}/series")
.andExpect {
status { isOk() }
jsonPath("$.content.length()") { value(10) }
}
}
@Test
@ -950,7 +957,7 @@ class SeriesCollectionControllerTest(
.andExpect {
status { isOk() }
jsonPath("$.name") { value("Lib1+2") }
jsonPath("$.ordered") { value(false) }
jsonPath("$.ordered") { value(true) }
jsonPath("$.seriesIds.length()") { value(1) }
}
}