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.selectAll import org.jetbrains.exposed.sql.transactions.transaction import suwayomi.tachidesk.graphql.queries.filter.BooleanFilter import suwayomi.tachidesk.graphql.queries.filter.DoubleFilter import suwayomi.tachidesk.graphql.queries.filter.Filter 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.applyBeforeAfter 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.TrackRecordNodeList import suwayomi.tachidesk.graphql.types.TrackRecordType import suwayomi.tachidesk.graphql.types.TrackSearchType import suwayomi.tachidesk.graphql.types.TrackerNodeList import suwayomi.tachidesk.graphql.types.TrackerType import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager import suwayomi.tachidesk.manga.model.table.TrackRecordTable import suwayomi.tachidesk.server.JavalinSetup.future import java.util.concurrent.CompletableFuture class TrackQuery { fun tracker( dataFetchingEnvironment: DataFetchingEnvironment, id: Int, ): CompletableFuture { return dataFetchingEnvironment.getValueFromDataLoader("TrackerDataLoader", id) } enum class TrackerOrderBy { ID, NAME, IS_LOGGED_IN, ; fun greater( tracker: TrackerType, cursor: Cursor, ): Boolean { return when (this) { ID -> tracker.id > cursor.value.toInt() NAME -> tracker.name > cursor.value IS_LOGGED_IN -> { val value = cursor.value.substringAfter('-').toBooleanStrict() !value || tracker.isLoggedIn } } } fun less( tracker: TrackerType, cursor: Cursor, ): Boolean { return when (this) { ID -> tracker.id < cursor.value.toInt() NAME -> tracker.name < cursor.value IS_LOGGED_IN -> { val value = cursor.value.substringAfter('-').toBooleanStrict() value || !tracker.isLoggedIn } } } fun asCursor(type: TrackerType): Cursor { val value = when (this) { ID -> type.id.toString() NAME -> type.name IS_LOGGED_IN -> type.id.toString() + "-" + type.isLoggedIn } return Cursor(value) } } data class TrackerCondition( val id: Int? = null, val name: String? = null, val icon: String? = null, val isLoggedIn: Boolean? = null, ) data class TrackerFilter( val id: IntFilter? = null, val name: StringFilter? = null, val icon: StringFilter? = null, val isLoggedIn: BooleanFilter? = null, val authUrl: StringFilter? = null, val and: List? = null, val or: List? = null, val not: TrackerFilter? = null, ) fun trackers( condition: TrackerCondition? = null, orderBy: TrackerOrderBy? = null, orderByType: SortOrder? = null, before: Cursor? = null, after: Cursor? = null, first: Int? = null, last: Int? = null, offset: Int? = null, ): TrackerNodeList { val (queryResults, resultsAsType) = run { var res = TrackerManager.services.map { TrackerType(it) } if (condition != null) { res = res.filter { tracker -> (condition.id == null || (condition.id == tracker.id)) && (condition.name == null || (condition.name == tracker.name)) && (condition.icon == null || (condition.icon == tracker.icon)) && (condition.isLoggedIn == null || (condition.isLoggedIn == tracker.isLoggedIn)) } } if (orderBy != null || (last != null || before != null)) { val orderType = orderByType.maybeSwap(last ?: before) res = when (orderType) { SortOrder.DESC, SortOrder.DESC_NULLS_FIRST, SortOrder.DESC_NULLS_LAST -> when (orderBy) { TrackerOrderBy.ID, null -> res.sortedByDescending { it.id } TrackerOrderBy.NAME -> res.sortedByDescending { it.name } TrackerOrderBy.IS_LOGGED_IN -> res.sortedByDescending { it.isLoggedIn } } SortOrder.ASC, SortOrder.ASC_NULLS_FIRST, SortOrder.ASC_NULLS_LAST -> when (orderBy) { TrackerOrderBy.ID, null -> res.sortedBy { it.id } TrackerOrderBy.NAME -> res.sortedBy { it.name } TrackerOrderBy.IS_LOGGED_IN -> res.sortedBy { it.isLoggedIn } } } } val total = res.size val firstResult = res.firstOrNull() val lastResult = res.lastOrNull() val realOrderBy = orderBy ?: TrackerOrderBy.ID if (after != null) { res = res.filter { when (orderByType) { SortOrder.DESC, SortOrder.DESC_NULLS_FIRST, SortOrder.DESC_NULLS_LAST -> realOrderBy.less(it, after) null, SortOrder.ASC, SortOrder.ASC_NULLS_FIRST, SortOrder.ASC_NULLS_LAST -> realOrderBy.greater(it, after) } } } else if (before != null) { res = res.filter { when (orderByType) { SortOrder.DESC, SortOrder.DESC_NULLS_FIRST, SortOrder.DESC_NULLS_LAST -> realOrderBy.greater(it, before) null, SortOrder.ASC, SortOrder.ASC_NULLS_FIRST, SortOrder.ASC_NULLS_LAST -> realOrderBy.less(it, before) } } } if (first != null) { res = res.drop(offset ?: 0).take(first) } else if (last != null) { res = res.take(last) } QueryResults(total.toLong(), firstResult, lastResult, emptyList()) to res } val getAsCursor: (TrackerType) -> Cursor = (orderBy ?: TrackerOrderBy.ID)::asCursor return TrackerNodeList( resultsAsType, if (resultsAsType.isEmpty()) { emptyList() } else { listOfNotNull( resultsAsType.firstOrNull()?.let { TrackerNodeList.TrackerEdge( getAsCursor(it), it, ) }, resultsAsType.lastOrNull()?.let { TrackerNodeList.TrackerEdge( getAsCursor(it), it, ) }, ) }, pageInfo = PageInfo( hasNextPage = queryResults.lastKey?.id != resultsAsType.lastOrNull()?.id, hasPreviousPage = queryResults.firstKey?.id != resultsAsType.firstOrNull()?.id, startCursor = resultsAsType.firstOrNull()?.let { getAsCursor(it) }, endCursor = resultsAsType.lastOrNull()?.let { getAsCursor(it) }, ), totalCount = queryResults.total.toInt(), ) } fun trackRecord( dataFetchingEnvironment: DataFetchingEnvironment, id: Int, ): CompletableFuture { return dataFetchingEnvironment.getValueFromDataLoader("TrackRecordDataLoader", id) } enum class TrackRecordOrderBy(override val column: Column>) : OrderBy { ID(TrackRecordTable.id), MANGA_ID(TrackRecordTable.mangaId), SYNC_ID(TrackRecordTable.syncId), REMOTE_ID(TrackRecordTable.remoteId), TITLE(TrackRecordTable.title), LAST_CHAPTER_READ(TrackRecordTable.lastChapterRead), TOTAL_CHAPTERS(TrackRecordTable.lastChapterRead), SCORE(TrackRecordTable.score), START_DATE(TrackRecordTable.startDate), FINISH_DATE(TrackRecordTable.finishDate), ; override fun greater(cursor: Cursor): Op { return when (this) { ID -> TrackRecordTable.id greater cursor.value.toInt() MANGA_ID -> greaterNotUnique(TrackRecordTable.mangaId, TrackRecordTable.id, cursor) SYNC_ID -> greaterNotUnique(TrackRecordTable.syncId, TrackRecordTable.id, cursor, String::toInt) REMOTE_ID -> greaterNotUnique(TrackRecordTable.remoteId, TrackRecordTable.id, cursor, String::toLong) TITLE -> greaterNotUnique(TrackRecordTable.title, TrackRecordTable.id, cursor, String::toString) LAST_CHAPTER_READ -> greaterNotUnique(TrackRecordTable.lastChapterRead, TrackRecordTable.id, cursor, String::toDouble) TOTAL_CHAPTERS -> greaterNotUnique(TrackRecordTable.totalChapters, TrackRecordTable.id, cursor, String::toInt) SCORE -> greaterNotUnique(TrackRecordTable.score, TrackRecordTable.id, cursor, String::toDouble) START_DATE -> greaterNotUnique(TrackRecordTable.startDate, TrackRecordTable.id, cursor, String::toLong) FINISH_DATE -> greaterNotUnique(TrackRecordTable.finishDate, TrackRecordTable.id, cursor, String::toLong) } } override fun less(cursor: Cursor): Op { return when (this) { ID -> TrackRecordTable.id less cursor.value.toInt() MANGA_ID -> lessNotUnique(TrackRecordTable.mangaId, TrackRecordTable.id, cursor) SYNC_ID -> lessNotUnique(TrackRecordTable.syncId, TrackRecordTable.id, cursor, String::toInt) REMOTE_ID -> lessNotUnique(TrackRecordTable.remoteId, TrackRecordTable.id, cursor, String::toLong) TITLE -> lessNotUnique(TrackRecordTable.title, TrackRecordTable.id, cursor, String::toString) LAST_CHAPTER_READ -> lessNotUnique(TrackRecordTable.lastChapterRead, TrackRecordTable.id, cursor, String::toDouble) TOTAL_CHAPTERS -> lessNotUnique(TrackRecordTable.totalChapters, TrackRecordTable.id, cursor, String::toInt) SCORE -> lessNotUnique(TrackRecordTable.score, TrackRecordTable.id, cursor, String::toDouble) START_DATE -> lessNotUnique(TrackRecordTable.startDate, TrackRecordTable.id, cursor, String::toLong) FINISH_DATE -> lessNotUnique(TrackRecordTable.finishDate, TrackRecordTable.id, cursor, String::toLong) } } override fun asCursor(type: TrackRecordType): Cursor { val value = when (this) { ID -> type.id.toString() MANGA_ID -> type.id.toString() + "-" + type.mangaId SYNC_ID -> type.id.toString() + "-" + type.syncId REMOTE_ID -> type.id.toString() + "-" + type.remoteId TITLE -> type.id.toString() + "-" + type.title LAST_CHAPTER_READ -> type.id.toString() + "-" + type.lastChapterRead TOTAL_CHAPTERS -> type.id.toString() + "-" + type.totalChapters SCORE -> type.id.toString() + "-" + type.score START_DATE -> type.id.toString() + "-" + type.startDate FINISH_DATE -> type.id.toString() + "-" + type.finishDate } return Cursor(value) } } data class TrackRecordCondition( val id: Int? = null, val mangaId: Int? = null, val syncId: Int? = null, val remoteId: Long? = null, val libraryId: Long? = null, val title: String? = null, val lastChapterRead: Double? = null, val totalChapters: Int? = null, val status: Int? = null, val score: Double? = null, val remoteUrl: String? = null, val startDate: Long? = null, val finishDate: Long? = null, ) : HasGetOp { override fun getOp(): Op? { val opAnd = OpAnd() opAnd.eq(id, TrackRecordTable.id) opAnd.eq(mangaId, TrackRecordTable.mangaId) opAnd.eq(syncId, TrackRecordTable.syncId) opAnd.eq(remoteId, TrackRecordTable.remoteId) opAnd.eq(libraryId, TrackRecordTable.libraryId) opAnd.eq(title, TrackRecordTable.title) opAnd.eq(lastChapterRead, TrackRecordTable.lastChapterRead) opAnd.eq(totalChapters, TrackRecordTable.totalChapters) opAnd.eq(status, TrackRecordTable.status) opAnd.eq(score, TrackRecordTable.score) opAnd.eq(remoteUrl, TrackRecordTable.remoteUrl) opAnd.eq(startDate, TrackRecordTable.startDate) opAnd.eq(finishDate, TrackRecordTable.finishDate) return opAnd.op } } data class TrackRecordFilter( val id: IntFilter? = null, val mangaId: IntFilter? = null, val syncId: IntFilter? = null, val remoteId: LongFilter? = null, val libraryId: LongFilter? = null, val title: StringFilter? = null, val lastChapterRead: DoubleFilter? = null, val totalChapters: IntFilter? = null, val status: IntFilter? = null, val score: DoubleFilter? = null, val remoteUrl: StringFilter? = null, val startDate: LongFilter? = null, val finishDate: LongFilter? = null, override val and: List? = null, override val or: List? = null, override val not: TrackRecordFilter? = null, ) : Filter { override fun getOpList(): List> { return listOfNotNull( andFilterWithCompareEntity(TrackRecordTable.id, id), andFilterWithCompareEntity(TrackRecordTable.mangaId, mangaId), andFilterWithCompare(TrackRecordTable.syncId, syncId), andFilterWithCompare(TrackRecordTable.remoteId, remoteId), andFilterWithCompare(TrackRecordTable.libraryId, libraryId), andFilterWithCompareString(TrackRecordTable.title, title), andFilterWithCompare(TrackRecordTable.lastChapterRead, lastChapterRead), andFilterWithCompare(TrackRecordTable.totalChapters, totalChapters), andFilterWithCompare(TrackRecordTable.status, status), andFilterWithCompare(TrackRecordTable.score, score), andFilterWithCompareString(TrackRecordTable.remoteUrl, remoteUrl), andFilterWithCompare(TrackRecordTable.startDate, startDate), andFilterWithCompare(TrackRecordTable.finishDate, finishDate), ) } } fun trackRecords( condition: TrackRecordCondition? = null, filter: TrackRecordFilter? = null, orderBy: TrackRecordOrderBy? = null, orderByType: SortOrder? = null, before: Cursor? = null, after: Cursor? = null, first: Int? = null, last: Int? = null, offset: Int? = null, ): TrackRecordNodeList { val queryResults = transaction { val res = TrackRecordTable.selectAll() res.applyOps(condition, filter) if (orderBy != null || (last != null || before != null)) { val orderByColumn = orderBy?.column ?: TrackRecordTable.id val orderType = orderByType.maybeSwap(last ?: before) if (orderBy == TrackRecordOrderBy.ID || orderBy == null) { res.orderBy(orderByColumn to orderType) } else { res.orderBy( orderByColumn to orderType, TrackRecordTable.id to SortOrder.ASC, ) } } val total = res.count() val firstResult = res.firstOrNull()?.get(TrackRecordTable.id)?.value val lastResult = res.lastOrNull()?.get(TrackRecordTable.id)?.value res.applyBeforeAfter( before = before, after = after, orderBy = orderBy ?: TrackRecordOrderBy.ID, orderByType = orderByType, ) if (first != null) { res.limit(first, offset?.toLong() ?: 0) } else if (last != null) { res.limit(last) } QueryResults(total, firstResult, lastResult, res.toList()) } val getAsCursor: (TrackRecordType) -> Cursor = (orderBy ?: TrackRecordOrderBy.ID)::asCursor val resultsAsType = queryResults.results.map { TrackRecordType(it) } return TrackRecordNodeList( resultsAsType, if (resultsAsType.isEmpty()) { emptyList() } else { listOfNotNull( resultsAsType.firstOrNull()?.let { TrackRecordNodeList.TrackRecordEdge( getAsCursor(it), it, ) }, resultsAsType.lastOrNull()?.let { TrackRecordNodeList.TrackRecordEdge( 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(), ) } data class SearchTrackerInput( val trackerId: Int, val query: String, ) data class SearchTrackerPayload(val trackSearches: List) fun searchTracker(input: SearchTrackerInput): CompletableFuture { return future { val tracker = requireNotNull(TrackerManager.getTracker(input.trackerId)) { "Tracker not found" } require(tracker.isLoggedIn) { "Tracker needs to be logged-in to search" } SearchTrackerPayload( tracker.search(input.query).map { TrackSearchType(it) }, ) } } }