From 885c89126700d6a5a2a153ffee346a1324d04e41 Mon Sep 17 00:00:00 2001 From: Gauthier Roebroeck Date: Fri, 5 Jun 2020 16:56:15 +0800 Subject: [PATCH] feat(api): read progress as search criteria for Series related to #25 --- .../gotson/komga/domain/model/BookSearch.kt | 3 - .../gotson/komga/domain/model/ReadStatus.kt | 5 + .../gotson/komga/domain/model/SeriesSearch.kt | 9 +- .../komga/infrastructure/jooq/SeriesDtoDao.kt | 74 ++++-- .../komga/interfaces/rest/SeriesController.kt | 14 +- .../rest/persistence/SeriesDtoRepository.kt | 6 +- .../infrastructure/jooq/SeriesDtoDaoTest.kt | 251 ++++++++++++++++++ 7 files changed, 331 insertions(+), 31 deletions(-) create mode 100644 komga/src/main/kotlin/org/gotson/komga/domain/model/ReadStatus.kt create mode 100644 komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDtoDaoTest.kt diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/BookSearch.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/BookSearch.kt index 35fca5df..4153bf2a 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/BookSearch.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/BookSearch.kt @@ -15,6 +15,3 @@ class BookSearchWithReadProgress( val readStatus: Collection = emptyList() ) : BookSearch(libraryIds, seriesIds, searchTerm, mediaStatus) -enum class ReadStatus { - UNREAD, READ, IN_PROGRESS -} diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/ReadStatus.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/ReadStatus.kt new file mode 100644 index 00000000..3f416385 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/ReadStatus.kt @@ -0,0 +1,5 @@ +package org.gotson.komga.domain.model + +enum class ReadStatus { + UNREAD, READ, IN_PROGRESS +} diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesSearch.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesSearch.kt index 89e91589..594fe22a 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesSearch.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesSearch.kt @@ -1,7 +1,14 @@ package org.gotson.komga.domain.model -data class SeriesSearch( +open class SeriesSearch( val libraryIds: Collection = emptyList(), val searchTerm: String? = null, val metadataStatus: Collection = emptyList() ) + +class SeriesSearchWithReadProgress( + libraryIds: Collection = emptyList(), + searchTerm: String? = null, + metadataStatus: Collection = emptyList(), + val readStatus: Collection = emptyList() +) : SeriesSearch(libraryIds, searchTerm, metadataStatus) diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDtoDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDtoDao.kt index 5c87080f..9933902a 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDtoDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDtoDao.kt @@ -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 = DSL.sum(DSL.`when`(r.COMPLETED.isNull, 1).otherwise(0)) + val countRead: AggregateFunction = DSL.sum(DSL.`when`(r.COMPLETED.isTrue, 1).otherwise(0)) + val countInProgress: AggregateFunction = 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 { + override fun findAll(search: SeriesSearchWithReadProgress, userId: Long, pageable: Pageable): Page { 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 { + override fun findRecentlyUpdated(search: SeriesSearchWithReadProgress, userId: Long, pageable: Pageable): Page { 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 { - val count = dsl.selectCount() + private fun findAll(conditions: Condition, having: Condition, userId: Long, pageable: Pageable): Page { + 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 = 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.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, diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesController.kt index 5a7783b0..bd1b96aa 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesController.kt @@ -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?, @RequestParam(name = "status", required = false) metadataStatus: List?, + @RequestParam(name = "read_status", required = false) readStatus: List?, @Parameter(hidden = true) page: Pageable ): Page { 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() 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() 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() else principal.user.sharedLibrariesIds return seriesDtoRepository.findRecentlyUpdated( - SeriesSearch(libraryIds = libraryIds), + SeriesSearchWithReadProgress(libraryIds = libraryIds), principal.user.id, pageRequest ).map { it.restrictUrl(!principal.user.roleAdmin) } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/persistence/SeriesDtoRepository.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/persistence/SeriesDtoRepository.kt index f8eb46fc..c7a61232 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/persistence/SeriesDtoRepository.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/persistence/SeriesDtoRepository.kt @@ -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 - fun findRecentlyUpdated(search: SeriesSearch, userId: Long, pageable: Pageable): Page + fun findAll(search: SeriesSearchWithReadProgress, userId: Long, pageable: Pageable): Page + fun findRecentlyUpdated(search: SeriesSearchWithReadProgress, userId: Long, pageable: Pageable): Page fun findByIdOrNull(seriesId: Long, userId: Long): SeriesDto? } diff --git a/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDtoDaoTest.kt b/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDtoDaoTest.kt new file mode 100644 index 00000000..94676042 --- /dev/null +++ b/komga/src/test/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDtoDaoTest.kt @@ -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") + } +}