diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/ChapterQuery.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/ChapterQuery.kt index 1363d99c..162b9f3d 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/ChapterQuery.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/ChapterQuery.kt @@ -9,19 +9,41 @@ package suwayomi.tachidesk.graphql.queries import com.expediagroup.graphql.server.extensions.getValueFromDataLoader import graphql.schema.DataFetchingEnvironment +import org.jetbrains.exposed.sql.Column +import org.jetbrains.exposed.sql.Op import org.jetbrains.exposed.sql.SortOrder +import org.jetbrains.exposed.sql.SqlExpressionBuilder.greater +import org.jetbrains.exposed.sql.SqlExpressionBuilder.less import org.jetbrains.exposed.sql.andWhere import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.transactions.transaction +import suwayomi.tachidesk.graphql.queries.filter.BooleanFilter +import suwayomi.tachidesk.graphql.queries.filter.Filter +import suwayomi.tachidesk.graphql.queries.filter.FloatFilter +import suwayomi.tachidesk.graphql.queries.filter.HasGetOp +import suwayomi.tachidesk.graphql.queries.filter.IntFilter +import suwayomi.tachidesk.graphql.queries.filter.LongFilter +import suwayomi.tachidesk.graphql.queries.filter.OpAnd +import suwayomi.tachidesk.graphql.queries.filter.StringFilter +import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompare +import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareEntity +import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareString +import suwayomi.tachidesk.graphql.queries.filter.applyOps +import suwayomi.tachidesk.graphql.server.primitives.Cursor +import suwayomi.tachidesk.graphql.server.primitives.OrderBy +import suwayomi.tachidesk.graphql.server.primitives.PageInfo +import suwayomi.tachidesk.graphql.server.primitives.QueryResults +import suwayomi.tachidesk.graphql.server.primitives.greaterNotUnique +import suwayomi.tachidesk.graphql.server.primitives.lessNotUnique +import suwayomi.tachidesk.graphql.server.primitives.maybeSwap import suwayomi.tachidesk.graphql.types.ChapterNodeList -import suwayomi.tachidesk.graphql.types.ChapterNodeList.Companion.toNodeList import suwayomi.tachidesk.graphql.types.ChapterType import suwayomi.tachidesk.manga.model.table.ChapterTable import java.util.concurrent.CompletableFuture /** * TODO Queries - * - Filter by scanlators + * - Filter in library * - Get page list? * * TODO Mutations @@ -37,68 +59,221 @@ class ChapterQuery { return dataFetchingEnvironment.getValueFromDataLoader("ChapterDataLoader", id) } - enum class ChapterSort { - SOURCE_ORDER, - NAME, - UPLOAD_DATE, - CHAPTER_NUMBER, - LAST_READ_AT, - FETCHED_AT - } + enum class ChapterOrderBy(override val column: Column>) : OrderBy { + ID(ChapterTable.id), + SOURCE_ORDER(ChapterTable.sourceOrder), + NAME(ChapterTable.name), + UPLOAD_DATE(ChapterTable.date_upload), + CHAPTER_NUMBER(ChapterTable.chapter_number), + LAST_READ_AT(ChapterTable.lastReadAt), + FETCHED_AT(ChapterTable.fetchedAt); - data class ChapterQueryInput( - val ids: List? = null, - val mangaIds: List? = null, - val read: Boolean? = null, - val bookmarked: Boolean? = null, - val downloaded: Boolean? = null, - val sort: ChapterSort? = null, - val sortOrder: SortOrder? = null, - val page: Int? = null, - val count: Int? = null - ) - - fun chapters(input: ChapterQueryInput? = null): ChapterNodeList { - val results = transaction { - var res = ChapterTable.selectAll() - - if (input != null) { - if (input.mangaIds != null) { - res.andWhere { ChapterTable.manga inList input.mangaIds } - } - if (input.ids != null) { - res.andWhere { ChapterTable.id inList input.ids } - } - if (input.read != null) { - res.andWhere { ChapterTable.isRead eq input.read } - } - if (input.bookmarked != null) { - res.andWhere { ChapterTable.isBookmarked eq input.bookmarked } - } - if (input.downloaded != null) { - res.andWhere { ChapterTable.isDownloaded eq input.downloaded } - } - val orderBy = when (input.sort) { - ChapterSort.SOURCE_ORDER, null -> ChapterTable.sourceOrder - ChapterSort.NAME -> ChapterTable.name - ChapterSort.UPLOAD_DATE -> ChapterTable.date_upload - ChapterSort.CHAPTER_NUMBER -> ChapterTable.chapter_number - ChapterSort.LAST_READ_AT -> ChapterTable.lastReadAt - ChapterSort.FETCHED_AT -> ChapterTable.fetchedAt - } - res.orderBy(orderBy, order = input.sortOrder ?: SortOrder.ASC) - - if (input.count != null) { - val offset = if (input.page == null) 0 else (input.page * input.count).toLong() - res.limit(input.count, offset) - } - } else { - res.orderBy(ChapterTable.sourceOrder) + override fun greater(cursor: Cursor): Op { + return when (this) { + ID -> ChapterTable.id greater cursor.value.toInt() + SOURCE_ORDER -> greaterNotUnique(ChapterTable.sourceOrder, ChapterTable.id, cursor, String::toInt) + NAME -> greaterNotUnique(ChapterTable.name, ChapterTable.id, cursor, String::toString) + UPLOAD_DATE -> greaterNotUnique(ChapterTable.date_upload, ChapterTable.id, cursor, String::toLong) + CHAPTER_NUMBER -> greaterNotUnique(ChapterTable.chapter_number, ChapterTable.id, cursor, String::toFloat) + LAST_READ_AT -> greaterNotUnique(ChapterTable.lastReadAt, ChapterTable.id, cursor, String::toLong) + FETCHED_AT -> greaterNotUnique(ChapterTable.fetchedAt, ChapterTable.id, cursor, String::toLong) } - - res.toList() } - return results.map { ChapterType(it) }.toNodeList() // todo paged + override fun less(cursor: Cursor): Op { + return when (this) { + ID -> ChapterTable.id less cursor.value.toInt() + SOURCE_ORDER -> lessNotUnique(ChapterTable.sourceOrder, ChapterTable.id, cursor, String::toInt) + NAME -> lessNotUnique(ChapterTable.name, ChapterTable.id, cursor, String::toString) + UPLOAD_DATE -> lessNotUnique(ChapterTable.date_upload, ChapterTable.id, cursor, String::toLong) + CHAPTER_NUMBER -> lessNotUnique(ChapterTable.chapter_number, ChapterTable.id, cursor, String::toFloat) + LAST_READ_AT -> lessNotUnique(ChapterTable.lastReadAt, ChapterTable.id, cursor, String::toLong) + FETCHED_AT -> lessNotUnique(ChapterTable.fetchedAt, ChapterTable.id, cursor, String::toLong) + } + } + + override fun asCursor(type: ChapterType): Cursor { + val value = when (this) { + ID -> type.id.toString() + SOURCE_ORDER -> type.id.toString() + "-" + type.sourceOrder + NAME -> type.id.toString() + "-" + type.name + UPLOAD_DATE -> type.id.toString() + "-" + type.uploadDate + CHAPTER_NUMBER -> type.id.toString() + "-" + type.chapterNumber + LAST_READ_AT -> type.id.toString() + "-" + type.lastReadAt + FETCHED_AT -> type.id.toString() + "-" + type.fetchedAt + } + return Cursor(value) + } + } + + data class ChapterCondition( + val id: Int? = null, + val url: String? = null, + val name: String? = null, + val uploadDate: Long? = null, + val chapterNumber: Float? = null, + val scanlator: String? = null, + val mangaId: Int? = null, + val isRead: Boolean? = null, + val isBookmarked: Boolean? = null, + val lastPageRead: Int? = null, + val lastReadAt: Long? = null, + val sourceOrder: Int? = null, + val realUrl: String? = null, + val fetchedAt: Long? = null, + val isDownloaded: Boolean? = null, + val pageCount: Int? = null + ) : HasGetOp { + override fun getOp(): Op? { + val opAnd = OpAnd() + opAnd.eq(id, ChapterTable.id) + opAnd.eq(url, ChapterTable.url) + opAnd.eq(name, ChapterTable.name) + opAnd.eq(uploadDate, ChapterTable.date_upload) + opAnd.eq(chapterNumber, ChapterTable.chapter_number) + opAnd.eq(scanlator, ChapterTable.scanlator) + opAnd.eq(mangaId, ChapterTable.manga) + opAnd.eq(isRead, ChapterTable.isRead) + opAnd.eq(isBookmarked, ChapterTable.isBookmarked) + opAnd.eq(lastPageRead, ChapterTable.lastPageRead) + opAnd.eq(lastReadAt, ChapterTable.lastReadAt) + opAnd.eq(sourceOrder, ChapterTable.sourceOrder) + opAnd.eq(realUrl, ChapterTable.realUrl) + opAnd.eq(fetchedAt, ChapterTable.fetchedAt) + opAnd.eq(isDownloaded, ChapterTable.isDownloaded) + opAnd.eq(pageCount, ChapterTable.pageCount) + + return opAnd.op + } + } + + data class ChapterFilter( + val id: IntFilter? = null, + val url: StringFilter? = null, + val name: StringFilter? = null, + val uploadDate: LongFilter? = null, + val chapterNumber: FloatFilter? = null, + val scanlator: StringFilter? = null, + val mangaId: IntFilter? = null, + val isRead: BooleanFilter? = null, + val isBookmarked: BooleanFilter? = null, + val lastPageRead: IntFilter? = null, + val lastReadAt: LongFilter? = null, + val sourceOrder: IntFilter? = null, + val realUrl: StringFilter? = null, + val fetchedAt: LongFilter? = null, + val isDownloaded: BooleanFilter? = null, + val pageCount: IntFilter? = null, + override val and: List? = null, + override val or: List? = null, + override val not: ChapterFilter? = null + ) : Filter { + override fun getOpList(): List> { + return listOfNotNull( + andFilterWithCompareEntity(ChapterTable.id, id), + andFilterWithCompareString(ChapterTable.url, url), + andFilterWithCompareString(ChapterTable.name, name), + andFilterWithCompare(ChapterTable.date_upload, uploadDate), + andFilterWithCompare(ChapterTable.chapter_number, chapterNumber), + andFilterWithCompareString(ChapterTable.scanlator, scanlator), + andFilterWithCompareEntity(ChapterTable.manga, mangaId), + andFilterWithCompare(ChapterTable.isRead, isRead), + andFilterWithCompare(ChapterTable.isBookmarked, isBookmarked), + andFilterWithCompare(ChapterTable.lastPageRead, lastPageRead), + andFilterWithCompare(ChapterTable.lastReadAt, lastReadAt), + andFilterWithCompare(ChapterTable.sourceOrder, sourceOrder), + andFilterWithCompareString(ChapterTable.realUrl, realUrl), + andFilterWithCompare(ChapterTable.fetchedAt, fetchedAt), + andFilterWithCompare(ChapterTable.isDownloaded, isDownloaded), + andFilterWithCompare(ChapterTable.pageCount, pageCount) + ) + } + } + + fun chapters( + condition: ChapterCondition? = null, + filter: ChapterFilter? = null, + orderBy: ChapterOrderBy? = null, + orderByType: SortOrder? = null, + before: Cursor? = null, + after: Cursor? = null, + first: Int? = null, + last: Int? = null, + offset: Int? = null + ): ChapterNodeList { + val queryResults = transaction { + val res = ChapterTable.selectAll() + + res.applyOps(condition, filter) + + if (orderBy != null || (last != null || before != null)) { + val orderByColumn = orderBy?.column ?: ChapterTable.id + val orderType = orderByType.maybeSwap(last ?: before) + + if (orderBy == ChapterOrderBy.ID || orderBy == null) { + res.orderBy(orderByColumn to orderType) + } else { + res.orderBy( + orderByColumn to orderType, + ChapterTable.id to SortOrder.ASC + ) + } + } + + val total = res.count() + val firstResult = res.firstOrNull()?.get(ChapterTable.id)?.value + val lastResult = res.lastOrNull()?.get(ChapterTable.id)?.value + + if (after != null) { + res.andWhere { + (orderBy ?: ChapterOrderBy.ID).greater(after) + } + } else if (before != null) { + res.andWhere { + (orderBy ?: ChapterOrderBy.ID).less(before) + } + } + + if (first != null) { + res.limit(first, offset?.toLong() ?: 0) + } else if (last != null) { + res.limit(last) + } + + QueryResults(total, firstResult, lastResult, res.toList()) + } + + val getAsCursor: (ChapterType) -> Cursor = (orderBy ?: ChapterOrderBy.ID)::asCursor + + val resultsAsType = queryResults.results.map { ChapterType(it) } + + return ChapterNodeList( + resultsAsType, + if (resultsAsType.isEmpty()) { + emptyList() + } else { + listOfNotNull( + resultsAsType.firstOrNull()?.let { + ChapterNodeList.ChapterEdge( + getAsCursor(it), + it + ) + }, + resultsAsType.lastOrNull()?.let { + ChapterNodeList.ChapterEdge( + getAsCursor(it), + it + ) + } + ) + }, + pageInfo = PageInfo( + hasNextPage = queryResults.lastKey != resultsAsType.lastOrNull()?.id, + hasPreviousPage = queryResults.firstKey != resultsAsType.firstOrNull()?.id, + startCursor = resultsAsType.firstOrNull()?.let { getAsCursor(it) }, + endCursor = resultsAsType.lastOrNull()?.let { getAsCursor(it) } + ), + totalCount = queryResults.total.toInt() + ) } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/filter/Filter.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/filter/Filter.kt index e66aa2a2..96458393 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/filter/Filter.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/filter/Filter.kt @@ -10,8 +10,6 @@ import org.jetbrains.exposed.sql.Op import org.jetbrains.exposed.sql.Query import org.jetbrains.exposed.sql.QueryBuilder import org.jetbrains.exposed.sql.SqlExpressionBuilder -import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq -import org.jetbrains.exposed.sql.SqlExpressionBuilder.wrap import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.andWhere import org.jetbrains.exposed.sql.not @@ -140,6 +138,12 @@ interface ComparableScalarFilter> : ScalarFilter { val greaterThanOrEqualTo: T? } +interface ListScalarFilter> : ScalarFilter { + val hasAny: List? + val hasAll: List? + val hasNone: List? +} + data class LongFilter( override val isNull: Boolean? = null, override val equalTo: Long? = null, @@ -182,6 +186,20 @@ data class IntFilter( override val greaterThanOrEqualTo: Int? = null ) : ComparableScalarFilter +data class FloatFilter( + override val isNull: Boolean? = null, + override val equalTo: Float? = null, + override val notEqualTo: Float? = null, + override val distinctFrom: Float? = null, + override val notDistinctFrom: Float? = null, + override val `in`: List? = null, + override val notIn: List? = null, + override val lessThan: Float? = null, + override val lessThanOrEqualTo: Float? = null, + override val greaterThan: Float? = null, + override val greaterThanOrEqualTo: Float? = null +) : ComparableScalarFilter + data class StringFilter( override val isNull: Boolean? = null, override val equalTo: String? = null, @@ -220,6 +238,22 @@ data class StringFilter( val greaterThanOrEqualToInsensitive: String? = null ) : ComparableScalarFilter +data class StringListFilter( + override val isNull: Boolean? = null, + override val equalTo: String? = null, + override val notEqualTo: String? = null, + override val distinctFrom: String? = null, + override val notDistinctFrom: String? = null, + override val `in`: List? = null, + override val notIn: List? = null, + override val hasAny: List? = null, + override val hasAll: List? = null, + override val hasNone: List? = null, + val hasAnyInsensitive: List? = null, + val hasAllInsensitive: List? = null, + val hasNoneInsensitive: List? = null +) : ListScalarFilter> + @Suppress("UNCHECKED_CAST") fun andFilterWithCompareString( column: Column, diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/ChapterType.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/ChapterType.kt index 8ec24372..321f8cf5 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/ChapterType.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/ChapterType.kt @@ -32,6 +32,7 @@ class ChapterType( val lastPageRead: Int, val lastReadAt: Long, val sourceOrder: Int, + val realUrl: String?, val fetchedAt: Long, val isDownloaded: Boolean, val pageCount: Int @@ -50,6 +51,7 @@ class ChapterType( row[ChapterTable.lastPageRead], row[ChapterTable.lastReadAt], row[ChapterTable.sourceOrder], + row[ChapterTable.realUrl], row[ChapterTable.fetchedAt], row[ChapterTable.isDownloaded], row[ChapterTable.pageCount] @@ -69,6 +71,7 @@ class ChapterType( dataClass.lastPageRead, dataClass.lastReadAt, dataClass.index, + dataClass.realUrl, dataClass.fetchedAt, dataClass.downloaded, dataClass.pageCount