feat(api): collections are pageable

related to #216
This commit is contained in:
Gauthier Roebroeck 2020-06-26 17:52:48 +08:00
parent 02e916898e
commit 449a27e136
9 changed files with 153 additions and 71 deletions

View file

@ -1,10 +1,12 @@
package org.gotson.komga.domain.persistence package org.gotson.komga.domain.persistence
import org.gotson.komga.domain.model.SeriesCollection import org.gotson.komga.domain.model.SeriesCollection
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
interface SeriesCollectionRepository { interface SeriesCollectionRepository {
fun findByIdOrNull(collectionId: Long): SeriesCollection? fun findByIdOrNull(collectionId: Long): SeriesCollection?
fun findAll(): Collection<SeriesCollection> fun findAll(search: String? = null, pageable: Pageable): Page<SeriesCollection>
/** /**
* Find one SeriesCollection by collectionId, * Find one SeriesCollection by collectionId,
@ -16,7 +18,7 @@ interface SeriesCollectionRepository {
* Find all SeriesCollection with at least one Series belonging to the provided belongsToLibraryIds, * Find all SeriesCollection with at least one Series belonging to the provided belongsToLibraryIds,
* optionally with only seriesId filtered by the provided filterOnLibraryIds. * optionally with only seriesId filtered by the provided filterOnLibraryIds.
*/ */
fun findAllByLibraries(belongsToLibraryIds: Collection<Long>, filterOnLibraryIds: Collection<Long>?): Collection<SeriesCollection> fun findAllByLibraries(belongsToLibraryIds: Collection<Long>, filterOnLibraryIds: Collection<Long>?, search: String? = null, pageable: Pageable): Page<SeriesCollection>
/** /**
* Find all SeriesCollection that contains the provided containsSeriesId, * Find all SeriesCollection that contains the provided containsSeriesId,

View file

@ -8,6 +8,11 @@ import org.gotson.komga.jooq.tables.records.CollectionRecord
import org.jooq.DSLContext import org.jooq.DSLContext
import org.jooq.Record import org.jooq.Record
import org.jooq.ResultQuery import org.jooq.ResultQuery
import org.jooq.impl.DSL
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.stereotype.Component import org.springframework.stereotype.Component
import java.time.LocalDateTime import java.time.LocalDateTime
@ -20,50 +25,79 @@ class SeriesCollectionDao(
private val cs = Tables.COLLECTION_SERIES private val cs = Tables.COLLECTION_SERIES
private val s = Tables.SERIES private val s = Tables.SERIES
private val groupFields = arrayOf(*c.fields(), *cs.fields()) private val sorts = mapOf(
"name" to DSL.lower(c.NAME)
)
override fun findByIdOrNull(collectionId: Long): SeriesCollection? = override fun findByIdOrNull(collectionId: Long): SeriesCollection? =
selectBase() selectBase()
.where(c.ID.eq(collectionId)) .where(c.ID.eq(collectionId))
.groupBy(*groupFields) .fetchAndMap(null)
.orderBy(cs.NUMBER.asc())
.fetchAndMap()
.firstOrNull() .firstOrNull()
override fun findByIdOrNull(collectionId: Long, filterOnLibraryIds: Collection<Long>?): SeriesCollection? = override fun findByIdOrNull(collectionId: Long, filterOnLibraryIds: Collection<Long>?): SeriesCollection? =
selectBase() selectBase()
.where(c.ID.eq(collectionId)) .where(c.ID.eq(collectionId))
.also { step -> .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } }
filterOnLibraryIds?.let { step.and(s.LIBRARY_ID.`in`(it)) } .fetchAndMap(filterOnLibraryIds)
}
.groupBy(*groupFields)
.orderBy(cs.NUMBER.asc())
.fetchAndMap()
.firstOrNull() .firstOrNull()
override fun findAll(): Collection<SeriesCollection> = override fun findAll(search: String?, pageable: Pageable): Page<SeriesCollection> {
selectBase() val conditions = search?.let { c.NAME.containsIgnoreCase(it) }
.groupBy(*groupFields) ?: DSL.trueCondition()
.orderBy(cs.NUMBER.asc())
.fetchAndMap()
override fun findAllByLibraries(belongsToLibraryIds: Collection<Long>, filterOnLibraryIds: Collection<Long>?): Collection<SeriesCollection> { val count = dsl.selectCount()
.from(c)
.where(conditions)
.fetchOne(0, Long::class.java)
val orderBy = pageable.sort.toOrderBy(sorts)
val items = selectBase()
.where(conditions)
.orderBy(orderBy)
.apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) }
.fetchAndMap(null)
return PageImpl(
items,
if (pageable.isPaged) PageRequest.of(pageable.pageNumber, pageable.pageSize, pageable.sort)
else PageRequest.of(0, count.toInt(), pageable.sort),
count.toLong()
)
}
override fun findAllByLibraries(belongsToLibraryIds: Collection<Long>, filterOnLibraryIds: Collection<Long>?, search: String?, pageable: Pageable): Page<SeriesCollection> {
val ids = dsl.select(c.ID) val ids = dsl.select(c.ID)
.from(c) .from(c)
.leftJoin(cs).on(c.ID.eq(cs.COLLECTION_ID)) .leftJoin(cs).on(c.ID.eq(cs.COLLECTION_ID))
.leftJoin(s).on(cs.SERIES_ID.eq(s.ID)) .leftJoin(s).on(cs.SERIES_ID.eq(s.ID))
.where(s.LIBRARY_ID.`in`(belongsToLibraryIds)) .where(s.LIBRARY_ID.`in`(belongsToLibraryIds))
.apply { search?.let { and(c.NAME.containsIgnoreCase(it)) } }
.fetch(0, Long::class.java) .fetch(0, Long::class.java)
return selectBase() val count = dsl.selectCount()
.from(c)
.where(c.ID.`in`(ids)) .where(c.ID.`in`(ids))
.also { step -> .fetchOne(0, Long::class.java)
filterOnLibraryIds?.let { step.and(s.LIBRARY_ID.`in`(it)) }
} val orderBy = pageable.sort.toOrderBy(sorts)
.groupBy(*groupFields)
.orderBy(cs.NUMBER.asc()) val items = selectBase()
.fetchAndMap() .where(c.ID.`in`(ids))
.apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } }
.apply { search?.let { and(c.NAME.containsIgnoreCase(it)) } }
.orderBy(orderBy)
.apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) }
.fetchAndMap(filterOnLibraryIds)
return PageImpl(
items,
if (pageable.isPaged) PageRequest.of(pageable.pageNumber, pageable.pageSize, pageable.sort)
else PageRequest.of(0, count.toInt()),
count.toLong()
)
} }
override fun findAllBySeries(containsSeriesId: Long, filterOnLibraryIds: Collection<Long>?): Collection<SeriesCollection> { override fun findAllBySeries(containsSeriesId: Long, filterOnLibraryIds: Collection<Long>?): Collection<SeriesCollection> {
@ -75,24 +109,27 @@ class SeriesCollectionDao(
return selectBase() return selectBase()
.where(c.ID.`in`(ids)) .where(c.ID.`in`(ids))
.also { step -> .apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } }
filterOnLibraryIds?.let { step.and(s.LIBRARY_ID.`in`(it)) } .fetchAndMap(filterOnLibraryIds)
}
.groupBy(*groupFields)
.orderBy(cs.NUMBER.asc())
.fetchAndMap()
} }
private fun selectBase() = private fun selectBase() =
dsl.select(*groupFields) dsl.selectDistinct(*c.fields())
.from(c) .from(c)
.leftJoin(cs).on(c.ID.eq(cs.COLLECTION_ID)) .leftJoin(cs).on(c.ID.eq(cs.COLLECTION_ID))
.leftJoin(s).on(cs.SERIES_ID.eq(s.ID)) .leftJoin(s).on(cs.SERIES_ID.eq(s.ID))
private fun ResultQuery<Record>.fetchAndMap() = private fun ResultQuery<Record>.fetchAndMap(filterOnLibraryIds: Collection<Long>?): List<SeriesCollection> =
fetchGroups({ it.into(c) }, { it.into(cs) }) fetchInto(c)
.map { (cr, csr) -> .map { cr ->
val seriesIds = csr.mapNotNull { it.seriesId } val seriesIds = dsl.select(*cs.fields())
.from(cs)
.leftJoin(s).on(cs.SERIES_ID.eq(s.ID))
.where(cs.COLLECTION_ID.eq(cr.id))
.apply { filterOnLibraryIds?.let { and(s.LIBRARY_ID.`in`(it)) } }
.orderBy(cs.NUMBER.asc())
.fetchInto(cs)
.mapNotNull { it.seriesId }
cr.toDomain(seriesIds) cr.toDomain(seriesIds)
} }

View file

@ -0,0 +1,33 @@
package org.gotson.komga.infrastructure.jooq
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
class UnpagedSorted(
private val sort: Sort
) : Pageable {
override fun getPageNumber(): Int {
throw UnsupportedOperationException()
}
override fun hasPrevious(): Boolean = false
override fun getSort(): Sort = sort
override fun isPaged(): Boolean = false
override fun next(): Pageable = this
override fun getPageSize(): Int {
throw UnsupportedOperationException()
}
override fun getOffset(): Long {
throw UnsupportedOperationException()
}
override fun first(): Pageable = this
override fun previousOrFirst(): Pageable = this
}

View file

@ -17,6 +17,7 @@ import org.gotson.komga.domain.persistence.MediaRepository
import org.gotson.komga.domain.persistence.SeriesCollectionRepository import org.gotson.komga.domain.persistence.SeriesCollectionRepository
import org.gotson.komga.domain.persistence.SeriesMetadataRepository import org.gotson.komga.domain.persistence.SeriesMetadataRepository
import org.gotson.komga.domain.persistence.SeriesRepository import org.gotson.komga.domain.persistence.SeriesRepository
import org.gotson.komga.infrastructure.jooq.UnpagedSorted
import org.gotson.komga.infrastructure.security.KomgaPrincipal import org.gotson.komga.infrastructure.security.KomgaPrincipal
import org.gotson.komga.interfaces.opds.dto.OpdsAuthor import org.gotson.komga.interfaces.opds.dto.OpdsAuthor
import org.gotson.komga.interfaces.opds.dto.OpdsEntryAcquisition import org.gotson.komga.interfaces.opds.dto.OpdsEntryAcquisition
@ -32,6 +33,7 @@ import org.gotson.komga.interfaces.opds.dto.OpdsLinkPageStreaming
import org.gotson.komga.interfaces.opds.dto.OpdsLinkRel import org.gotson.komga.interfaces.opds.dto.OpdsLinkRel
import org.gotson.komga.interfaces.opds.dto.OpdsLinkSearch import org.gotson.komga.interfaces.opds.dto.OpdsLinkSearch
import org.gotson.komga.interfaces.opds.dto.OpenSearchDescription import org.gotson.komga.interfaces.opds.dto.OpenSearchDescription
import org.springframework.data.domain.Sort
import org.springframework.http.HttpHeaders import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.MediaType import org.springframework.http.MediaType
@ -223,11 +225,12 @@ class OpdsController(
fun getCollections( fun getCollections(
@AuthenticationPrincipal principal: KomgaPrincipal @AuthenticationPrincipal principal: KomgaPrincipal
): OpdsFeed { ): OpdsFeed {
val pageRequest = UnpagedSorted(Sort.by(Sort.Order.asc("name")))
val collections = val collections =
if (principal.user.sharedAllLibraries) { if (principal.user.sharedAllLibraries) {
collectionRepository.findAll() collectionRepository.findAll(pageable = pageRequest)
} else { } else {
collectionRepository.findAllByLibraries(principal.user.sharedLibrariesIds, principal.user.sharedLibrariesIds) collectionRepository.findAllByLibraries(principal.user.sharedLibrariesIds, principal.user.sharedLibrariesIds, pageable = pageRequest)
} }
return OpdsFeedNavigation( return OpdsFeedNavigation(
id = ID_COLLECTIONS_ALL, id = ID_COLLECTIONS_ALL,
@ -238,7 +241,7 @@ class OpdsController(
OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "$routeBase$ROUTE_COLLECTIONS_ALL"), OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "$routeBase$ROUTE_COLLECTIONS_ALL"),
linkStart linkStart
), ),
entries = collections.map { it.toOpdsEntry() } entries = collections.content.map { it.toOpdsEntry() }
) )
} }

View file

@ -11,8 +11,10 @@ import org.gotson.komga.domain.model.SeriesCollection
import org.gotson.komga.domain.persistence.SeriesCollectionRepository import org.gotson.komga.domain.persistence.SeriesCollectionRepository
import org.gotson.komga.domain.service.SeriesCollectionLifecycle import org.gotson.komga.domain.service.SeriesCollectionLifecycle
import org.gotson.komga.infrastructure.image.MosaicGenerator import org.gotson.komga.infrastructure.image.MosaicGenerator
import org.gotson.komga.infrastructure.jooq.UnpagedSorted
import org.gotson.komga.infrastructure.security.KomgaPrincipal import org.gotson.komga.infrastructure.security.KomgaPrincipal
import org.gotson.komga.infrastructure.swagger.PageableAsQueryParam import org.gotson.komga.infrastructure.swagger.PageableAsQueryParam
import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam
import org.gotson.komga.interfaces.rest.dto.CollectionCreationDto import org.gotson.komga.interfaces.rest.dto.CollectionCreationDto
import org.gotson.komga.interfaces.rest.dto.CollectionDto import org.gotson.komga.interfaces.rest.dto.CollectionDto
import org.gotson.komga.interfaces.rest.dto.CollectionUpdateDto import org.gotson.komga.interfaces.rest.dto.CollectionUpdateDto
@ -20,7 +22,10 @@ import org.gotson.komga.interfaces.rest.dto.SeriesDto
import org.gotson.komga.interfaces.rest.dto.restrictUrl import org.gotson.komga.interfaces.rest.dto.restrictUrl
import org.gotson.komga.interfaces.rest.dto.toDto import org.gotson.komga.interfaces.rest.dto.toDto
import org.gotson.komga.interfaces.rest.persistence.SeriesDtoRepository import org.gotson.komga.interfaces.rest.persistence.SeriesDtoRepository
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import org.springframework.http.CacheControl import org.springframework.http.CacheControl
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.MediaType import org.springframework.http.MediaType
@ -53,17 +58,30 @@ class SeriesCollectionController(
private val mosaicGenerator: MosaicGenerator private val mosaicGenerator: MosaicGenerator
) { ) {
@PageableWithoutSortAsQueryParam
@GetMapping @GetMapping
fun getAll( fun getAll(
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@RequestParam(name = "library_id", required = false) libraryIds: List<Long>? @RequestParam(name = "search", required = false) searchTerm: String?,
): List<CollectionDto> = @RequestParam(name = "library_id", required = false) libraryIds: List<Long>?,
when { @RequestParam(name = "unpaged", required = false) unpaged: Boolean = false,
principal.user.sharedAllLibraries && libraryIds == null -> collectionRepository.findAll() @Parameter(hidden = true) page: Pageable
principal.user.sharedAllLibraries && libraryIds != null -> collectionRepository.findAllByLibraries(libraryIds, null) ): Page<CollectionDto> {
!principal.user.sharedAllLibraries && libraryIds != null -> collectionRepository.findAllByLibraries(libraryIds, principal.user.sharedLibrariesIds) val pageRequest =
else -> collectionRepository.findAllByLibraries(principal.user.sharedLibrariesIds, principal.user.sharedLibrariesIds) if (unpaged) UnpagedSorted(Sort.by(Sort.Order.asc("name")))
}.sortedBy { it.name.toLowerCase() }.map { it.toDto() } else PageRequest.of(
page.pageNumber,
page.pageSize,
Sort.by(Sort.Order.asc("name"))
)
return when {
principal.user.sharedAllLibraries && libraryIds == null -> collectionRepository.findAll(searchTerm, pageable = pageRequest)
principal.user.sharedAllLibraries && libraryIds != null -> collectionRepository.findAllByLibraries(libraryIds, null, searchTerm, pageable = pageRequest)
!principal.user.sharedAllLibraries && libraryIds != null -> collectionRepository.findAllByLibraries(libraryIds, principal.user.sharedLibrariesIds, searchTerm, pageable = pageRequest)
else -> collectionRepository.findAllByLibraries(principal.user.sharedLibrariesIds, principal.user.sharedLibrariesIds, searchTerm, pageable = pageRequest)
}.map { it.toDto() }
}
@GetMapping("{id}") @GetMapping("{id}")
fun getOne( fun getOne(

View file

@ -12,12 +12,6 @@ import org.gotson.komga.Application
@AnalyzeClasses(packagesOf = [Application::class], importOptions = [ImportOption.DoNotIncludeTests::class]) @AnalyzeClasses(packagesOf = [Application::class], importOptions = [ImportOption.DoNotIncludeTests::class])
class DomainDrivenDesignRulesTest { class DomainDrivenDesignRulesTest {
@ArchTest
val domain_persistence_can_only_contain_interfaces: ArchRule =
classes()
.that().resideInAPackage("..domain..persistence..")
.should().beInterfaces()
@ArchTest @ArchTest
val domain_model_should_not_access_other_packages: ArchRule = val domain_model_should_not_access_other_packages: ArchRule =
noClasses() noClasses()

View file

@ -14,12 +14,6 @@ import org.springframework.web.bind.annotation.RestController
class NamingConventionTest { class NamingConventionTest {
@ArchTest
val domain_persistence_should_have_names_ending_with_repository: ArchRule =
classes()
.that().resideInAPackage("..domain..persistence..")
.should().haveNameMatching(".*Repository")
@ArchTest @ArchTest
val services_should_not_have_names_containing_service_or_manager: ArchRule = val services_should_not_have_names_containing_service_or_manager: ArchRule =
noClasses() noClasses()

View file

@ -14,6 +14,7 @@ import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.SpringBootTest
import org.springframework.data.domain.Pageable
import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.test.context.junit.jupiter.SpringExtension
import java.time.LocalDateTime import java.time.LocalDateTime
@ -164,11 +165,11 @@ class SeriesCollectionDaoTest(
)) ))
// when // when
val foundLibrary1Filtered = collectionDao.findAllByLibraries(listOf(library.id), listOf(library.id)) val foundLibrary1Filtered = collectionDao.findAllByLibraries(listOf(library.id), listOf(library.id), pageable = Pageable.unpaged()).content
val foundLibrary1Unfiltered = collectionDao.findAllByLibraries(listOf(library.id), null) val foundLibrary1Unfiltered = collectionDao.findAllByLibraries(listOf(library.id), null, pageable = Pageable.unpaged()).content
val foundLibrary2Filtered = collectionDao.findAllByLibraries(listOf(library2.id), listOf(library2.id)) val foundLibrary2Filtered = collectionDao.findAllByLibraries(listOf(library2.id), listOf(library2.id), pageable = Pageable.unpaged()).content
val foundLibrary2Unfiltered = collectionDao.findAllByLibraries(listOf(library2.id), null) val foundLibrary2Unfiltered = collectionDao.findAllByLibraries(listOf(library2.id), null, pageable = Pageable.unpaged()).content
val foundBothUnfiltered = collectionDao.findAllByLibraries(listOf(library.id, library2.id), null) val foundBothUnfiltered = collectionDao.findAllByLibraries(listOf(library.id, library2.id), null, pageable = Pageable.unpaged()).content
// then // then
assertThat(foundLibrary1Filtered).hasSize(2) assertThat(foundLibrary1Filtered).hasSize(2)

View file

@ -114,10 +114,10 @@ class SeriesCollectionControllerTest(
mockMvc.get("/api/v1/collections") mockMvc.get("/api/v1/collections")
.andExpect { .andExpect {
status { isOk } status { isOk }
jsonPath("$.length()") { value(3) } jsonPath("$.totalElements") { value(3) }
jsonPath("$[?(@.name == 'Lib1')].filtered") { value(false) } jsonPath("$.content[?(@.name == 'Lib1')].filtered") { value(false) }
jsonPath("$[?(@.name == 'Lib2')].filtered") { value(false) } jsonPath("$.content[?(@.name == 'Lib2')].filtered") { value(false) }
jsonPath("$[?(@.name == 'Lib1+2')].filtered") { value(false) } jsonPath("$.content[?(@.name == 'Lib1+2')].filtered") { value(false) }
} }
} }
@ -129,9 +129,9 @@ class SeriesCollectionControllerTest(
mockMvc.get("/api/v1/collections") mockMvc.get("/api/v1/collections")
.andExpect { .andExpect {
status { isOk } status { isOk }
jsonPath("$.length()") { value(2) } jsonPath("$.totalElements") { value(2) }
jsonPath("$[?(@.name == 'Lib1')].filtered") { value(false) } jsonPath("$.content[?(@.name == 'Lib1')].filtered") { value(false) }
jsonPath("$[?(@.name == 'Lib1+2')].filtered") { value(true) } jsonPath("$.content[?(@.name == 'Lib1+2')].filtered") { value(true) }
} }
} }