feat(api): read progress as search criteria for Series

related to #25
This commit is contained in:
Gauthier Roebroeck 2020-06-05 16:56:15 +08:00
parent 8d59203efe
commit 885c891267
7 changed files with 331 additions and 31 deletions

View file

@ -15,6 +15,3 @@ class BookSearchWithReadProgress(
val readStatus: Collection<ReadStatus> = emptyList()
) : BookSearch(libraryIds, seriesIds, searchTerm, mediaStatus)
enum class ReadStatus {
UNREAD, READ, IN_PROGRESS
}

View file

@ -0,0 +1,5 @@
package org.gotson.komga.domain.model
enum class ReadStatus {
UNREAD, READ, IN_PROGRESS
}

View file

@ -1,7 +1,14 @@
package org.gotson.komga.domain.model
data class SeriesSearch(
open class SeriesSearch(
val libraryIds: Collection<Long> = emptyList(),
val searchTerm: String? = null,
val metadataStatus: Collection<SeriesMetadata.Status> = emptyList()
)
class SeriesSearchWithReadProgress(
libraryIds: Collection<Long> = emptyList(),
searchTerm: String? = null,
metadataStatus: Collection<SeriesMetadata.Status> = emptyList(),
val readStatus: Collection<ReadStatus> = emptyList()
) : SeriesSearch(libraryIds, searchTerm, metadataStatus)

View file

@ -1,6 +1,7 @@
package org.gotson.komga.infrastructure.jooq
import org.gotson.komga.domain.model.SeriesSearch
import org.gotson.komga.domain.model.ReadStatus
import org.gotson.komga.domain.model.SeriesSearchWithReadProgress
import org.gotson.komga.interfaces.rest.dto.SeriesDto
import org.gotson.komga.interfaces.rest.dto.SeriesMetadataDto
import org.gotson.komga.interfaces.rest.dto.toUTC
@ -8,18 +9,26 @@ import org.gotson.komga.interfaces.rest.persistence.SeriesDtoRepository
import org.gotson.komga.jooq.Tables
import org.gotson.komga.jooq.tables.records.SeriesMetadataRecord
import org.gotson.komga.jooq.tables.records.SeriesRecord
import org.jooq.AggregateFunction
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.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 java.math.BigDecimal
import java.net.URL
const val BOOKS_COUNT = "booksCount"
const val BOOKS_UNREAD_COUNT = "booksUnreadCount"
const val BOOKS_IN_PROGRESS_COUNT = "booksInProgressCount"
const val BOOKS_READ_COUNT = "booksReadCount"
@Component
class SeriesDtoDao(
private val dsl: DSLContext
@ -35,23 +44,33 @@ class SeriesDtoDao(
*d.fields()
)
val countUnread: AggregateFunction<BigDecimal> = DSL.sum(DSL.`when`(r.COMPLETED.isNull, 1).otherwise(0))
val countRead: AggregateFunction<BigDecimal> = DSL.sum(DSL.`when`(r.COMPLETED.isTrue, 1).otherwise(0))
val countInProgress: AggregateFunction<BigDecimal> = DSL.sum(DSL.`when`(r.COMPLETED.isFalse, 1).otherwise(0))
private val sorts = mapOf(
"metadata.titleSort" to DSL.lower(d.TITLE_SORT),
"createdDate" to s.CREATED_DATE,
"lastModifiedDate" to s.LAST_MODIFIED_DATE
"created" to s.CREATED_DATE,
"lastModifiedDate" to s.LAST_MODIFIED_DATE,
"lastModified" to s.LAST_MODIFIED_DATE
)
override fun findAll(search: SeriesSearch, userId: Long, pageable: Pageable): Page<SeriesDto> {
override fun findAll(search: SeriesSearchWithReadProgress, userId: Long, pageable: Pageable): Page<SeriesDto> {
val conditions = search.toCondition()
return findAll(conditions, userId, pageable)
val having = search.readStatus.toCondition()
return findAll(conditions, having, userId, pageable)
}
override fun findRecentlyUpdated(search: SeriesSearch, userId: Long, pageable: Pageable): Page<SeriesDto> {
override fun findRecentlyUpdated(search: SeriesSearchWithReadProgress, userId: Long, pageable: Pageable): Page<SeriesDto> {
val conditions = search.toCondition()
.and(s.CREATED_DATE.ne(s.LAST_MODIFIED_DATE))
return findAll(conditions, userId, pageable)
val having = search.readStatus.toCondition()
return findAll(conditions, having, userId, pageable)
}
override fun findByIdOrNull(seriesId: Long, userId: Long): SeriesDto? =
@ -63,12 +82,18 @@ class SeriesDtoDao(
.firstOrNull()
private fun findAll(conditions: Condition, userId: Long, pageable: Pageable): Page<SeriesDto> {
val count = dsl.selectCount()
private fun findAll(conditions: Condition, having: Condition, userId: Long, pageable: Pageable): Page<SeriesDto> {
val count = dsl.select(s.ID)
.from(s)
.leftJoin(b).on(s.ID.eq(b.SERIES_ID))
.leftJoin(d).on(s.ID.eq(d.SERIES_ID))
.leftJoin(r).on(b.ID.eq(r.BOOK_ID))
.where(conditions)
.fetchOne(0, Int::class.java)
.and(readProgressCondition(userId))
.groupBy(s.ID)
.having(having)
.fetch()
.size
val orderBy = pageable.sort.toOrderBy(sorts)
@ -76,6 +101,7 @@ class SeriesDtoDao(
.where(conditions)
.and(readProgressCondition(userId))
.groupBy(*groupFields)
.having(having)
.orderBy(orderBy)
.limit(pageable.pageSize)
.offset(pageable.offset)
@ -88,11 +114,12 @@ class SeriesDtoDao(
)
}
private fun selectBase() =
private fun selectBase(): SelectOnConditionStep<Record> =
dsl.select(*groupFields)
.select(DSL.count(b.ID).`as`("booksCount"))
.select(DSL.sum(DSL.`when`(r.COMPLETED.isTrue, 1).otherwise(0)).`as`("booksReadCount"))
.select(DSL.sum(DSL.`when`(r.COMPLETED.isFalse, 1).otherwise(0)).`as`("booksInProgressCount"))
.select(DSL.count(b.ID).`as`(BOOKS_COUNT))
.select(countUnread.`as`(BOOKS_UNREAD_COUNT))
.select(countRead.`as`(BOOKS_READ_COUNT))
.select(countInProgress.`as`(BOOKS_IN_PROGRESS_COUNT))
.from(s)
.leftJoin(b).on(s.ID.eq(b.SERIES_ID))
.leftJoin(d).on(s.ID.eq(d.SERIES_ID))
@ -105,14 +132,14 @@ class SeriesDtoDao(
.map { r ->
val sr = r.into(s)
val dr = r.into(d)
val booksCount = r.get("booksCount", Int::class.java)
val booksReadCount = r.get("booksReadCount", Int::class.java)
val booksInProgressCount = r.get("booksInProgressCount", Int::class.java)
val booksUnreadCount = booksCount - booksInProgressCount - booksReadCount
val booksCount = r.get(BOOKS_COUNT, Int::class.java)
val booksUnreadCount = r.get(BOOKS_UNREAD_COUNT, Int::class.java)
val booksReadCount = r.get(BOOKS_READ_COUNT, Int::class.java)
val booksInProgressCount = r.get(BOOKS_IN_PROGRESS_COUNT, Int::class.java)
sr.toDto(booksCount, booksReadCount, booksUnreadCount, booksInProgressCount, dr.toDto())
}
private fun SeriesSearch.toCondition(): Condition {
private fun SeriesSearchWithReadProgress.toCondition(): Condition {
var c: Condition = DSL.trueCondition()
if (libraryIds.isNotEmpty()) c = c.and(s.LIBRARY_ID.`in`(libraryIds))
@ -122,6 +149,17 @@ class SeriesDtoDao(
return c
}
private fun Collection<ReadStatus>.toCondition(): Condition =
if (isNotEmpty()) {
map {
when (it) {
ReadStatus.UNREAD -> countUnread.ge(1.toBigDecimal())
ReadStatus.READ -> countRead.ge(1.toBigDecimal())
ReadStatus.IN_PROGRESS -> countInProgress.ge(1.toBigDecimal())
}
}.reduce { acc, condition -> acc.or(condition) }
} else DSL.trueCondition()
private fun SeriesRecord.toDto(booksCount: Int, booksReadCount: Int, booksUnreadCount: Int, booksInProgressCount: Int, metadata: SeriesMetadataDto) =
SeriesDto(
id = id,

View file

@ -11,7 +11,7 @@ import org.gotson.komga.domain.model.BookSearchWithReadProgress
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.ReadStatus
import org.gotson.komga.domain.model.SeriesMetadata
import org.gotson.komga.domain.model.SeriesSearch
import org.gotson.komga.domain.model.SeriesSearchWithReadProgress
import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.SeriesMetadataRepository
import org.gotson.komga.domain.persistence.SeriesRepository
@ -69,6 +69,7 @@ class SeriesController(
@RequestParam(name = "search", required = false) searchTerm: String?,
@RequestParam(name = "library_id", required = false) libraryIds: List<Long>?,
@RequestParam(name = "status", required = false) metadataStatus: List<SeriesMetadata.Status>?,
@RequestParam(name = "read_status", required = false) readStatus: List<ReadStatus>?,
@Parameter(hidden = true) page: Pageable
): Page<SeriesDto> {
val pageRequest = PageRequest.of(
@ -78,10 +79,11 @@ class SeriesController(
else Sort.by(Sort.Order.asc("metadata.titleSort").ignoreCase())
)
val seriesSearch = SeriesSearch(
val seriesSearch = SeriesSearchWithReadProgress(
libraryIds = principal.user.getAuthorizedLibraryIds(libraryIds),
searchTerm = searchTerm,
metadataStatus = metadataStatus ?: emptyList()
metadataStatus = metadataStatus ?: emptyList(),
readStatus = readStatus ?: emptyList()
)
return seriesDtoRepository.findAll(seriesSearch, principal.user.id, pageRequest)
@ -104,7 +106,7 @@ class SeriesController(
val libraryIds = if (principal.user.sharedAllLibraries) emptyList<Long>() else principal.user.sharedLibrariesIds
return seriesDtoRepository.findAll(
SeriesSearch(libraryIds = libraryIds),
SeriesSearchWithReadProgress(libraryIds = libraryIds),
principal.user.id,
pageRequest
).map { it.restrictUrl(!principal.user.roleAdmin) }
@ -126,7 +128,7 @@ class SeriesController(
val libraryIds = if (principal.user.sharedAllLibraries) emptyList<Long>() else principal.user.sharedLibrariesIds
return seriesDtoRepository.findAll(
SeriesSearch(libraryIds = libraryIds),
SeriesSearchWithReadProgress(libraryIds = libraryIds),
principal.user.id,
pageRequest
).map { it.restrictUrl(!principal.user.roleAdmin) }
@ -148,7 +150,7 @@ class SeriesController(
val libraryIds = if (principal.user.sharedAllLibraries) emptyList<Long>() else principal.user.sharedLibrariesIds
return seriesDtoRepository.findRecentlyUpdated(
SeriesSearch(libraryIds = libraryIds),
SeriesSearchWithReadProgress(libraryIds = libraryIds),
principal.user.id,
pageRequest
).map { it.restrictUrl(!principal.user.roleAdmin) }

View file

@ -1,12 +1,12 @@
package org.gotson.komga.interfaces.rest.persistence
import org.gotson.komga.domain.model.SeriesSearch
import org.gotson.komga.domain.model.SeriesSearchWithReadProgress
import org.gotson.komga.interfaces.rest.dto.SeriesDto
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
interface SeriesDtoRepository {
fun findAll(search: SeriesSearch, userId: Long, pageable: Pageable): Page<SeriesDto>
fun findRecentlyUpdated(search: SeriesSearch, userId: Long, pageable: Pageable): Page<SeriesDto>
fun findAll(search: SeriesSearchWithReadProgress, userId: Long, pageable: Pageable): Page<SeriesDto>
fun findRecentlyUpdated(search: SeriesSearchWithReadProgress, userId: Long, pageable: Pageable): Page<SeriesDto>
fun findByIdOrNull(seriesId: Long, userId: Long): SeriesDto?
}

View file

@ -0,0 +1,251 @@
package org.gotson.komga.infrastructure.jooq
import org.assertj.core.api.Assertions.assertThat
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.SeriesSearchWithReadProgress
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.BookRepository
import org.gotson.komga.domain.persistence.KomgaUserRepository
import org.gotson.komga.domain.persistence.LibraryRepository
import org.gotson.komga.domain.persistence.ReadProgressRepository
import org.gotson.komga.domain.persistence.SeriesRepository
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.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.data.domain.PageRequest
import org.springframework.test.context.junit.jupiter.SpringExtension
@ExtendWith(SpringExtension::class)
@SpringBootTest
@AutoConfigureTestDatabase
class SeriesDtoDaoTest(
@Autowired private val seriesDtoDao: SeriesDtoDao,
@Autowired private val bookRepository: BookRepository,
@Autowired private val bookLifecycle: BookLifecycle,
@Autowired private val seriesRepository: SeriesRepository,
@Autowired private val seriesLifecycle: SeriesLifecycle,
@Autowired private val libraryRepository: LibraryRepository,
@Autowired private val libraryLifecycle: LibraryLifecycle,
@Autowired private val readProgressRepository: ReadProgressRepository,
@Autowired private val userRepository: KomgaUserRepository,
@Autowired private val userLifecycle: KomgaUserLifecycle
) {
private var library = makeLibrary()
private var user = KomgaUser("user@example.org", "", false)
@BeforeAll
fun setup() {
library = libraryRepository.insert(library)
user = userRepository.save(user)
}
@AfterEach
fun deleteSeries() {
seriesRepository.findAll().forEach {
seriesLifecycle.deleteSeries(it.id)
}
}
@AfterAll
fun tearDown() {
userRepository.findAll().forEach {
userLifecycle.deleteUser(it)
}
libraryRepository.findAll().forEach {
libraryLifecycle.deleteLibrary(it)
}
}
private fun setupSeries() {
(1..4).map { makeSeries("$it", library.id) }
.forEach { series ->
val created = seriesLifecycle.createSeries(series)
seriesLifecycle.addBooks(created,
(1..3).map {
makeBook("$it", seriesId = created.id, libraryId = library.id)
})
}
val series = seriesRepository.findAll().sortedBy { it.name }
// series "1": only in progress books
series.elementAt(0).let {
bookRepository.findBySeriesId(it.id).forEach { readProgressRepository.save(ReadProgress(it.id, user.id, 5, false)) }
}
// series "2": only read books
series.elementAt(1).let {
bookRepository.findBySeriesId(it.id).forEach { readProgressRepository.save(ReadProgress(it.id, user.id, 5, true)) }
}
// series "3": only unread books
// series "4": read, unread, and in progress
series.elementAt(3).let {
val books = bookRepository.findBySeriesId(it.id).sortedBy { it.name }
books.elementAt(0).let { readProgressRepository.save(ReadProgress(it.id, user.id, 5, false)) }
books.elementAt(1).let { readProgressRepository.save(ReadProgress(it.id, user.id, 5, true)) }
}
}
@Test
fun `given series in various read status when searching for read series then only read series are returned`() {
// given
setupSeries()
// when
val found = seriesDtoDao.findAll(
SeriesSearchWithReadProgress(readStatus = listOf(ReadStatus.READ)),
user.id,
PageRequest.of(0, 20)
).sortedBy { it.name }
// then
assertThat(found).hasSize(2)
assertThat(found.first().booksReadCount).isEqualTo(3)
assertThat(found.first().name).isEqualTo("2")
assertThat(found.last().booksReadCount).isEqualTo(1)
assertThat(found.last().name).isEqualTo("4")
}
@Test
fun `given series in various read status when searching for unread series then only unread series are returned`() {
// given
setupSeries()
// when
val found = seriesDtoDao.findAll(
SeriesSearchWithReadProgress(readStatus = listOf(ReadStatus.UNREAD)),
user.id,
PageRequest.of(0, 20)
).sortedBy { it.name }
// then
assertThat(found).hasSize(2)
assertThat(found.first().booksUnreadCount).isEqualTo(3)
assertThat(found.first().name).isEqualTo("3")
assertThat(found.last().booksUnreadCount).isEqualTo(1)
assertThat(found.last().name).isEqualTo("4")
}
@Test
fun `given series in various read status when searching for in progress series then only in progress series are returned`() {
// given
setupSeries()
// when
val found = seriesDtoDao.findAll(
SeriesSearchWithReadProgress(readStatus = listOf(ReadStatus.IN_PROGRESS)),
user.id,
PageRequest.of(0, 20)
).sortedBy { it.name }
// then
assertThat(found).hasSize(2)
assertThat(found.first().booksInProgressCount).isEqualTo(3)
assertThat(found.first().name).isEqualTo("1")
assertThat(found.last().booksInProgressCount).isEqualTo(1)
assertThat(found.last().name).isEqualTo("4")
}
@Test
fun `given series in various read status when searching for read and unread series then only matching series are returned`() {
// given
setupSeries()
// when
val found = seriesDtoDao.findAll(
SeriesSearchWithReadProgress(readStatus = listOf(ReadStatus.READ, ReadStatus.UNREAD)),
user.id,
PageRequest.of(0, 20)
).sortedBy { it.name }
// then
assertThat(found).hasSize(3)
assertThat(found.map { it.name }).containsExactlyInAnyOrder("2", "3", "4")
}
@Test
fun `given series in various read status when searching for read and in progress series then only matching series are returned`() {
// given
setupSeries()
// when
val found = seriesDtoDao.findAll(
SeriesSearchWithReadProgress(readStatus = listOf(ReadStatus.READ, ReadStatus.IN_PROGRESS)),
user.id,
PageRequest.of(0, 20)
).sortedBy { it.name }
// then
assertThat(found).hasSize(3)
assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2", "4")
}
@Test
fun `given series in various read status when searching for unread and in progress series then only matching series are returned`() {
// given
setupSeries()
// when
val found = seriesDtoDao.findAll(
SeriesSearchWithReadProgress(readStatus = listOf(ReadStatus.UNREAD, ReadStatus.IN_PROGRESS)),
user.id,
PageRequest.of(0, 20)
).sortedBy { it.name }
// then
assertThat(found).hasSize(3)
assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "3", "4")
}
@Test
fun `given series in various read status when searching for read and unread and in progress series then only matching series are returned`() {
// given
setupSeries()
// when
val found = seriesDtoDao.findAll(
SeriesSearchWithReadProgress(readStatus = listOf(ReadStatus.READ, ReadStatus.IN_PROGRESS, ReadStatus.UNREAD)),
user.id,
PageRequest.of(0, 20)
).sortedBy { it.name }
// then
assertThat(found).hasSize(4)
assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2", "3", "4")
}
@Test
fun `given series in various read status when searching without read progress then all series are returned`() {
// given
setupSeries()
// when
val found = seriesDtoDao.findAll(
SeriesSearchWithReadProgress(),
user.id,
PageRequest.of(0, 20)
).sortedBy { it.name }
// then
assertThat(found).hasSize(4)
assertThat(found.map { it.name }).containsExactlyInAnyOrder("1", "2", "3", "4")
}
}