Merge pull request #547 from Suwayomi/graphql

add graphql
This commit is contained in:
Aria Moradi
2023-05-13 23:00:13 +03:30
committed by GitHub
45 changed files with 4389 additions and 2 deletions

View File

@@ -64,6 +64,10 @@ dependencies {
// implementation(fileTree("lib/"))
implementation(kotlin("script-runtime"))
implementation("com.expediagroup:graphql-kotlin-server:6.4.0")
implementation("com.expediagroup:graphql-kotlin-schema-generator:6.4.0")
implementation("com.graphql-java:graphql-java-extended-scalars:20.0")
testImplementation(libs.mockk)
}

View File

@@ -0,0 +1,23 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
package suwayomi.tachidesk.graphql
import io.javalin.apibuilder.ApiBuilder.get
import io.javalin.apibuilder.ApiBuilder.post
import io.javalin.apibuilder.ApiBuilder.ws
import suwayomi.tachidesk.graphql.controller.GraphQLController
object GraphQL {
fun defineEndpoints() {
post("graphql", GraphQLController::execute)
ws("graphql", GraphQLController::webSocket)
// graphql playground
get("graphql", GraphQLController::playground)
}
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
package suwayomi.tachidesk.graphql.controller
import io.javalin.http.Context
import io.javalin.websocket.WsConfig
import suwayomi.tachidesk.graphql.server.TachideskGraphQLServer
import suwayomi.tachidesk.server.JavalinSetup.future
object GraphQLController {
private val server = TachideskGraphQLServer.create()
/** execute graphql query */
fun execute(ctx: Context) {
ctx.future(
future {
server.execute(ctx)
}
)
}
fun playground(ctx: Context) {
val body = javaClass.getResourceAsStream("/graphql-playground.html")!!.bufferedReader().use { reader ->
reader.readText()
}
ctx.html(body)
}
fun webSocket(ws: WsConfig) {
ws.onMessage { ctx ->
server.handleSubscriptionMessage(ctx)
}
ws.onClose { ctx ->
server.handleSubscriptionDisconnect(ctx)
}
}
}

View File

@@ -0,0 +1,54 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
package suwayomi.tachidesk.graphql.dataLoaders
import com.expediagroup.graphql.dataloader.KotlinDataLoader
import org.dataloader.DataLoader
import org.dataloader.DataLoaderFactory
import org.jetbrains.exposed.sql.Slf4jSqlDebugLogger
import org.jetbrains.exposed.sql.addLogger
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.graphql.types.CategoryNodeList
import suwayomi.tachidesk.graphql.types.CategoryNodeList.Companion.toNodeList
import suwayomi.tachidesk.graphql.types.CategoryType
import suwayomi.tachidesk.manga.model.table.CategoryMangaTable
import suwayomi.tachidesk.manga.model.table.CategoryTable
import suwayomi.tachidesk.server.JavalinSetup.future
class CategoryDataLoader : KotlinDataLoader<Int, CategoryType?> {
override val dataLoaderName = "CategoryDataLoader"
override fun getDataLoader(): DataLoader<Int, CategoryType?> = DataLoaderFactory.newDataLoader { ids ->
future {
transaction {
addLogger(Slf4jSqlDebugLogger)
val categories = CategoryTable.select { CategoryTable.id inList ids }
.map { CategoryType(it) }
.associateBy { it.id }
ids.map { categories[it] }
}
}
}
}
class CategoriesForMangaDataLoader : KotlinDataLoader<Int, CategoryNodeList> {
override val dataLoaderName = "CategoriesForMangaDataLoader"
override fun getDataLoader(): DataLoader<Int, CategoryNodeList> = DataLoaderFactory.newDataLoader<Int, CategoryNodeList> { ids ->
future {
transaction {
addLogger(Slf4jSqlDebugLogger)
val itemsByRef = CategoryMangaTable.innerJoin(CategoryTable)
.select { CategoryMangaTable.manga inList ids }
.map { Pair(it[CategoryMangaTable.manga].value, CategoryType(it)) }
.groupBy { it.first }
.mapValues { it.value.map { pair -> pair.second } }
ids.map { (itemsByRef[it] ?: emptyList()).toNodeList() }
}
}
}
}

View File

@@ -0,0 +1,51 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
package suwayomi.tachidesk.graphql.dataLoaders
import com.expediagroup.graphql.dataloader.KotlinDataLoader
import org.dataloader.DataLoader
import org.dataloader.DataLoaderFactory
import org.jetbrains.exposed.sql.Slf4jSqlDebugLogger
import org.jetbrains.exposed.sql.addLogger
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
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 suwayomi.tachidesk.server.JavalinSetup.future
class ChapterDataLoader : KotlinDataLoader<Int, ChapterType?> {
override val dataLoaderName = "ChapterDataLoader"
override fun getDataLoader(): DataLoader<Int, ChapterType?> = DataLoaderFactory.newDataLoader<Int, ChapterType> { ids ->
future {
transaction {
addLogger(Slf4jSqlDebugLogger)
val chapters = ChapterTable.select { ChapterTable.id inList ids }
.map { ChapterType(it) }
.associateBy { it.id }
ids.map { chapters[it] }
}
}
}
}
class ChaptersForMangaDataLoader : KotlinDataLoader<Int, ChapterNodeList> {
override val dataLoaderName = "ChaptersForMangaDataLoader"
override fun getDataLoader(): DataLoader<Int, ChapterNodeList> = DataLoaderFactory.newDataLoader<Int, ChapterNodeList> { ids ->
future {
transaction {
addLogger(Slf4jSqlDebugLogger)
val chaptersByMangaId = ChapterTable.select { ChapterTable.manga inList ids }
.map { ChapterType(it) }
.groupBy { it.mangaId }
ids.map { (chaptersByMangaId[it] ?: emptyList()).toNodeList() }
}
}
}
}

View File

@@ -0,0 +1,63 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
package suwayomi.tachidesk.graphql.dataLoaders
import com.expediagroup.graphql.dataloader.KotlinDataLoader
import org.dataloader.DataLoader
import org.dataloader.DataLoaderFactory
import org.jetbrains.exposed.sql.Slf4jSqlDebugLogger
import org.jetbrains.exposed.sql.addLogger
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.graphql.types.ExtensionType
import suwayomi.tachidesk.manga.model.table.ExtensionTable
import suwayomi.tachidesk.manga.model.table.SourceTable
import suwayomi.tachidesk.server.JavalinSetup.future
class ExtensionDataLoader : KotlinDataLoader<String, ExtensionType?> {
override val dataLoaderName = "ExtensionDataLoader"
override fun getDataLoader(): DataLoader<String, ExtensionType?> = DataLoaderFactory.newDataLoader { ids ->
future {
transaction {
addLogger(Slf4jSqlDebugLogger)
val extensions = ExtensionTable.select { ExtensionTable.pkgName inList ids }
.map { ExtensionType(it) }
.associateBy { it.pkgName }
ids.map { extensions[it] }
}
}
}
}
class ExtensionForSourceDataLoader : KotlinDataLoader<Long, ExtensionType?> {
override val dataLoaderName = "ExtensionForSourceDataLoader"
override fun getDataLoader(): DataLoader<Long, ExtensionType?> = DataLoaderFactory.newDataLoader { ids ->
future {
transaction {
addLogger(Slf4jSqlDebugLogger)
val extensions = ExtensionTable.innerJoin(SourceTable)
.select { SourceTable.id inList ids }
.toList()
.map { Triple(it[SourceTable.id].value, it[ExtensionTable.pkgName], it) }
.let { triples ->
val sources = buildMap {
triples.forEach {
if (!containsKey(it.second)) {
put(it.second, ExtensionType(it.third))
}
}
}
triples.associate {
it.first to sources[it.second]
}
}
ids.map { extensions[it] }
}
}
}
}

View File

@@ -0,0 +1,53 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
package suwayomi.tachidesk.graphql.dataLoaders
import com.expediagroup.graphql.dataloader.KotlinDataLoader
import org.dataloader.DataLoader
import org.dataloader.DataLoaderFactory
import org.jetbrains.exposed.sql.Slf4jSqlDebugLogger
import org.jetbrains.exposed.sql.addLogger
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.graphql.types.MangaNodeList
import suwayomi.tachidesk.graphql.types.MangaNodeList.Companion.toNodeList
import suwayomi.tachidesk.graphql.types.MangaType
import suwayomi.tachidesk.manga.model.table.CategoryMangaTable
import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.server.JavalinSetup.future
class MangaDataLoader : KotlinDataLoader<Int, MangaType?> {
override val dataLoaderName = "MangaDataLoader"
override fun getDataLoader(): DataLoader<Int, MangaType?> = DataLoaderFactory.newDataLoader { ids ->
future {
transaction {
addLogger(Slf4jSqlDebugLogger)
val manga = MangaTable.select { MangaTable.id inList ids }
.map { MangaType(it) }
.associateBy { it.id }
ids.map { manga[it] }
}
}
}
}
class MangaForCategoryDataLoader : KotlinDataLoader<Int, MangaNodeList> {
override val dataLoaderName = "MangaForCategoryDataLoader"
override fun getDataLoader(): DataLoader<Int, MangaNodeList> = DataLoaderFactory.newDataLoader<Int, MangaNodeList> { ids ->
future {
transaction {
addLogger(Slf4jSqlDebugLogger)
val itemsByRef = CategoryMangaTable.innerJoin(MangaTable).select { CategoryMangaTable.category inList ids }
.map { Pair(it[CategoryMangaTable.category].value, MangaType(it)) }
.groupBy { it.first }
.mapValues { it.value.map { pair -> pair.second } }
ids.map { (itemsByRef[it] ?: emptyList()).toNodeList() }
}
}
}
}

View File

@@ -0,0 +1,80 @@
package suwayomi.tachidesk.graphql.dataLoaders
import com.expediagroup.graphql.dataloader.KotlinDataLoader
import org.dataloader.DataLoader
import org.dataloader.DataLoaderFactory
import org.jetbrains.exposed.sql.Slf4jSqlDebugLogger
import org.jetbrains.exposed.sql.addLogger
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.global.model.table.GlobalMetaTable
import suwayomi.tachidesk.graphql.types.CategoryMetaItem
import suwayomi.tachidesk.graphql.types.ChapterMetaItem
import suwayomi.tachidesk.graphql.types.GlobalMetaItem
import suwayomi.tachidesk.graphql.types.MangaMetaItem
import suwayomi.tachidesk.graphql.types.MetaItem
import suwayomi.tachidesk.graphql.types.MetaNodeList
import suwayomi.tachidesk.graphql.types.MetaNodeList.Companion.toNodeList
import suwayomi.tachidesk.manga.model.table.ChapterMetaTable
import suwayomi.tachidesk.manga.model.table.MangaMetaTable
import suwayomi.tachidesk.server.JavalinSetup.future
class GlobalMetaDataLoader : KotlinDataLoader<String, MetaItem?> {
override val dataLoaderName = "GlobalMetaDataLoader"
override fun getDataLoader(): DataLoader<String, MetaItem?> = DataLoaderFactory.newDataLoader<String, MetaItem?> { ids ->
future {
transaction {
addLogger(Slf4jSqlDebugLogger)
val metasByRefId = GlobalMetaTable.select { GlobalMetaTable.key inList ids }
.map { GlobalMetaItem(it) }
.associateBy { it.key }
ids.map { metasByRefId[it] }
}
}
}
}
class ChapterMetaDataLoader : KotlinDataLoader<Int, MetaNodeList> {
override val dataLoaderName = "ChapterMetaDataLoader"
override fun getDataLoader(): DataLoader<Int, MetaNodeList> = DataLoaderFactory.newDataLoader<Int, MetaNodeList> { ids ->
future {
transaction {
addLogger(Slf4jSqlDebugLogger)
val metasByRefId = ChapterMetaTable.select { ChapterMetaTable.ref inList ids }
.map { ChapterMetaItem(it) }
.groupBy { it.ref }
ids.map { (metasByRefId[it] ?: emptyList()).toNodeList() }
}
}
}
}
class MangaMetaDataLoader : KotlinDataLoader<Int, MetaNodeList> {
override val dataLoaderName = "MangaMetaDataLoader"
override fun getDataLoader(): DataLoader<Int, MetaNodeList> = DataLoaderFactory.newDataLoader<Int, MetaNodeList> { ids ->
future {
transaction {
addLogger(Slf4jSqlDebugLogger)
val metasByRefId = MangaMetaTable.select { MangaMetaTable.ref inList ids }
.map { MangaMetaItem(it) }
.groupBy { it.ref }
ids.map { (metasByRefId[it] ?: emptyList()).toNodeList() }
}
}
}
}
class CategoryMetaDataLoader : KotlinDataLoader<Int, MetaNodeList> {
override val dataLoaderName = "CategoryMetaDataLoader"
override fun getDataLoader(): DataLoader<Int, MetaNodeList> = DataLoaderFactory.newDataLoader<Int, MetaNodeList> { ids ->
future {
transaction {
addLogger(Slf4jSqlDebugLogger)
val metasByRefId = MangaMetaTable.select { MangaMetaTable.ref inList ids }
.map { CategoryMetaItem(it) }
.groupBy { it.ref }
ids.map { (metasByRefId[it] ?: emptyList()).toNodeList() }
}
}
}
}

View File

@@ -0,0 +1,86 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
package suwayomi.tachidesk.graphql.dataLoaders
import com.expediagroup.graphql.dataloader.KotlinDataLoader
import org.dataloader.DataLoader
import org.dataloader.DataLoaderFactory
import org.jetbrains.exposed.sql.Slf4jSqlDebugLogger
import org.jetbrains.exposed.sql.addLogger
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.graphql.types.SourceNodeList
import suwayomi.tachidesk.graphql.types.SourceNodeList.Companion.toNodeList
import suwayomi.tachidesk.graphql.types.SourceType
import suwayomi.tachidesk.manga.model.table.ExtensionTable
import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.manga.model.table.SourceTable
import suwayomi.tachidesk.server.JavalinSetup.future
class SourceDataLoader : KotlinDataLoader<Long, SourceType?> {
override val dataLoaderName = "SourceDataLoader"
override fun getDataLoader(): DataLoader<Long, SourceType?> = DataLoaderFactory.newDataLoader { ids ->
future {
transaction {
addLogger(Slf4jSqlDebugLogger)
val source = SourceTable.select { SourceTable.id inList ids }
.mapNotNull { SourceType(it) }
.associateBy { it.id }
ids.map { source[it] }
}
}
}
}
class SourceForMangaDataLoader : KotlinDataLoader<Int, SourceType?> {
override val dataLoaderName = "SourceForMangaDataLoader"
override fun getDataLoader(): DataLoader<Int, SourceType?> = DataLoaderFactory.newDataLoader { ids ->
future {
transaction {
addLogger(Slf4jSqlDebugLogger)
val itemsByRef = MangaTable.innerJoin(SourceTable)
.select { MangaTable.id inList ids }
.map { Triple(it[MangaTable.id].value, it[MangaTable.sourceReference], it) }
.let { triples ->
val sources = buildMap {
triples.forEach {
if (!containsKey(it.second)) {
put(it.second, SourceType(it.third))
}
}
}
triples.associate {
it.first to sources[it.second]
}
}
ids.map { itemsByRef[it] }
}
}
}
}
class SourcesForExtensionDataLoader : KotlinDataLoader<String, SourceNodeList> {
override val dataLoaderName = "SourcesForExtensionDataLoader"
override fun getDataLoader(): DataLoader<String, SourceNodeList> = DataLoaderFactory.newDataLoader { ids ->
future {
transaction {
addLogger(Slf4jSqlDebugLogger)
val sourcesByExtensionPkg = SourceTable.innerJoin(ExtensionTable)
.select { ExtensionTable.pkgName inList ids }
.map { Pair(it[ExtensionTable.pkgName], SourceType(it)) }
.groupBy { it.first }
.mapValues { it.value.mapNotNull { pair -> pair.second } }
ids.map { (sourcesByExtensionPkg[it] ?: emptyList()).toNodeList() }
}
}
}
}

View File

@@ -0,0 +1,91 @@
package suwayomi.tachidesk.graphql.mutations
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
import com.expediagroup.graphql.server.extensions.getValuesFromDataLoader
import graphql.schema.DataFetchingEnvironment
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.graphql.types.ChapterType
import suwayomi.tachidesk.manga.model.table.ChapterTable
import java.time.Instant
import java.util.concurrent.CompletableFuture
/**
* TODO Mutations
* - Check for updates?
* - Download
* - Delete download
*/
class ChapterMutation {
data class UpdateChapterPatch(
val isBookmarked: Boolean? = null,
val isRead: Boolean? = null,
val lastPageRead: Int? = null
)
data class UpdateChapterPayload(
val clientMutationId: String?,
val chapter: ChapterType
)
data class UpdateChapterInput(
val clientMutationId: String? = null,
val id: Int,
val patch: UpdateChapterPatch
)
data class UpdateChaptersPayload(
val clientMutationId: String?,
val chapters: List<ChapterType>
)
data class UpdateChaptersInput(
val clientMutationId: String? = null,
val ids: List<Int>,
val patch: UpdateChapterPatch
)
private fun updateChapters(ids: List<Int>, patch: UpdateChapterPatch) {
transaction {
if (patch.isRead != null || patch.isBookmarked != null || patch.lastPageRead != null) {
val now = Instant.now().epochSecond
ChapterTable.update({ ChapterTable.id inList ids }) { update ->
patch.isRead?.also {
update[isRead] = it
}
patch.isBookmarked?.also {
update[isBookmarked] = it
}
patch.lastPageRead?.also {
update[lastPageRead] = it
update[lastReadAt] = now
}
}
}
}
}
fun updateChapter(dataFetchingEnvironment: DataFetchingEnvironment, input: UpdateChapterInput): CompletableFuture<UpdateChapterPayload> {
val (clientMutationId, id, patch) = input
updateChapters(listOf(id), patch)
return dataFetchingEnvironment.getValueFromDataLoader<Int, ChapterType>("ChapterDataLoader", id).thenApply { chapter ->
UpdateChapterPayload(
clientMutationId = clientMutationId,
chapter = chapter
)
}
}
fun updateChapters(dataFetchingEnvironment: DataFetchingEnvironment, input: UpdateChaptersInput): CompletableFuture<UpdateChaptersPayload> {
val (clientMutationId, ids, patch) = input
updateChapters(ids, patch)
return dataFetchingEnvironment.getValuesFromDataLoader<Int, ChapterType>("ChapterDataLoader", ids).thenApply { chapters ->
UpdateChaptersPayload(
clientMutationId = clientMutationId,
chapters = chapters
)
}
}
}

View File

@@ -0,0 +1,84 @@
package suwayomi.tachidesk.graphql.mutations
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
import com.expediagroup.graphql.server.extensions.getValuesFromDataLoader
import graphql.schema.DataFetchingEnvironment
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.graphql.types.MangaType
import suwayomi.tachidesk.manga.model.table.MangaTable
import java.util.concurrent.CompletableFuture
/**
* TODO Mutations
* - Add to category
* - Remove from category
* - Check for updates
* - Download x(all = -1) chapters
* - Delete read/all downloaded chapters
* - Add/update meta
* - Delete meta
*/
class MangaMutation {
data class UpdateMangaPatch(
val inLibrary: Boolean? = null
)
data class UpdateMangaPayload(
val clientMutationId: String?,
val manga: MangaType
)
data class UpdateMangaInput(
val clientMutationId: String? = null,
val id: Int,
val patch: UpdateMangaPatch
)
data class UpdateMangasPayload(
val clientMutationId: String?,
val mangas: List<MangaType>
)
data class UpdateMangasInput(
val clientMutationId: String?? = null,
val ids: List<Int>,
val patch: UpdateMangaPatch
)
private fun updateMangas(ids: List<Int>, patch: UpdateMangaPatch) {
transaction {
if (patch.inLibrary != null) {
MangaTable.update({ MangaTable.id inList ids }) { update ->
patch.inLibrary.also {
update[inLibrary] = it
}
}
}
}
}
fun updateManga(dataFetchingEnvironment: DataFetchingEnvironment, input: UpdateMangaInput): CompletableFuture<UpdateMangaPayload> {
val (clientMutationId, id, patch) = input
updateMangas(listOf(id), patch)
return dataFetchingEnvironment.getValueFromDataLoader<Int, MangaType>("MangaDataLoader", id).thenApply { manga ->
UpdateMangaPayload(
clientMutationId = clientMutationId,
manga = manga
)
}
}
fun updateMangas(dataFetchingEnvironment: DataFetchingEnvironment, input: UpdateMangasInput): CompletableFuture<UpdateMangasPayload> {
val (clientMutationId, ids, patch) = input
updateMangas(ids, patch)
return dataFetchingEnvironment.getValuesFromDataLoader<Int, MangaType>("MangaDataLoader", ids).thenApply { mangas ->
UpdateMangasPayload(
clientMutationId = clientMutationId,
mangas = mangas
)
}
}
}

View File

@@ -0,0 +1,212 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
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.HasGetOp
import suwayomi.tachidesk.graphql.queries.filter.IntFilter
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.CategoryNodeList
import suwayomi.tachidesk.graphql.types.CategoryType
import suwayomi.tachidesk.manga.model.table.CategoryTable
import java.util.concurrent.CompletableFuture
/**
* TODO Queries
*
* TODO Mutations
* - Name
* - Order
* - Default
* - Create
* - Delete
* - Add/update meta
* - Delete meta
*/
class CategoryQuery {
fun category(dataFetchingEnvironment: DataFetchingEnvironment, id: Int): CompletableFuture<CategoryType?> {
return dataFetchingEnvironment.getValueFromDataLoader("CategoryDataLoader", id)
}
enum class CategoryOrderBy(override val column: Column<out Comparable<*>>) : OrderBy<CategoryType> {
ID(CategoryTable.id),
NAME(CategoryTable.name),
ORDER(CategoryTable.order);
override fun greater(cursor: Cursor): Op<Boolean> {
return when (this) {
ID -> CategoryTable.id greater cursor.value.toInt()
NAME -> greaterNotUnique(CategoryTable.name, CategoryTable.id, cursor, String::toString)
ORDER -> greaterNotUnique(CategoryTable.order, CategoryTable.id, cursor, String::toInt)
}
}
override fun less(cursor: Cursor): Op<Boolean> {
return when (this) {
ID -> CategoryTable.id less cursor.value.toInt()
NAME -> lessNotUnique(CategoryTable.name, CategoryTable.id, cursor, String::toString)
ORDER -> lessNotUnique(CategoryTable.order, CategoryTable.id, cursor, String::toInt)
}
}
override fun asCursor(type: CategoryType): Cursor {
val value = when (this) {
ID -> type.id.toString()
NAME -> type.id.toString() + "-" + type.name
ORDER -> type.id.toString() + "-" + type.order
}
return Cursor(value)
}
}
data class CategoryCondition(
val id: Int? = null,
val order: Int? = null,
val name: String? = null,
val default: Boolean? = null
) : HasGetOp {
override fun getOp(): Op<Boolean>? {
val opAnd = OpAnd()
opAnd.eq(id, CategoryTable.id)
opAnd.eq(order, CategoryTable.order)
opAnd.eq(name, CategoryTable.name)
opAnd.eq(default, CategoryTable.isDefault)
return opAnd.op
}
}
data class CategoryFilter(
val id: IntFilter? = null,
val order: IntFilter? = null,
val name: StringFilter? = null,
val default: BooleanFilter? = null,
override val and: List<CategoryFilter>? = null,
override val or: List<CategoryFilter>? = null,
override val not: CategoryFilter? = null
) : Filter<CategoryFilter> {
override fun getOpList(): List<Op<Boolean>> {
return listOfNotNull(
andFilterWithCompareEntity(CategoryTable.id, id),
andFilterWithCompare(CategoryTable.order, order),
andFilterWithCompareString(CategoryTable.name, name),
andFilterWithCompare(CategoryTable.isDefault, default)
)
}
}
fun categories(
condition: CategoryCondition? = null,
filter: CategoryFilter? = null,
orderBy: CategoryOrderBy? = null,
orderByType: SortOrder? = null,
before: Cursor? = null,
after: Cursor? = null,
first: Int? = null,
last: Int? = null,
offset: Int? = null
): CategoryNodeList {
val queryResults = transaction {
val res = CategoryTable.selectAll()
res.applyOps(condition, filter)
if (orderBy != null || (last != null || before != null)) {
val orderByColumn = orderBy?.column ?: CategoryTable.id
val orderType = orderByType.maybeSwap(last ?: before)
if (orderBy == CategoryOrderBy.ID || orderBy == null) {
res.orderBy(orderByColumn to orderType)
} else {
res.orderBy(
orderByColumn to orderType,
CategoryTable.id to SortOrder.ASC
)
}
}
val total = res.count()
val firstResult = res.firstOrNull()?.get(CategoryTable.id)?.value
val lastResult = res.lastOrNull()?.get(CategoryTable.id)?.value
if (after != null) {
res.andWhere {
(orderBy ?: CategoryOrderBy.ID).greater(after)
}
} else if (before != null) {
res.andWhere {
(orderBy ?: CategoryOrderBy.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: (CategoryType) -> Cursor = (orderBy ?: CategoryOrderBy.ID)::asCursor
val resultsAsType = queryResults.results.map { CategoryType(it) }
return CategoryNodeList(
resultsAsType,
if (resultsAsType.isEmpty()) {
emptyList()
} else {
listOfNotNull(
resultsAsType.firstOrNull()?.let {
CategoryNodeList.CategoryEdge(
getAsCursor(it),
it
)
},
resultsAsType.lastOrNull()?.let {
CategoryNodeList.CategoryEdge(
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()
)
}
}

View File

@@ -0,0 +1,283 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
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.ChapterType
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.MangaTable
import java.util.concurrent.CompletableFuture
/**
* TODO Queries
* - Filter in library
* - Get page list?
*/
class ChapterQuery {
fun chapter(dataFetchingEnvironment: DataFetchingEnvironment, id: Int): CompletableFuture<ChapterType?> {
return dataFetchingEnvironment.getValueFromDataLoader("ChapterDataLoader", id)
}
enum class ChapterOrderBy(override val column: Column<out Comparable<*>>) : OrderBy<ChapterType> {
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);
override fun greater(cursor: Cursor): Op<Boolean> {
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)
}
}
override fun less(cursor: Cursor): Op<Boolean> {
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<Boolean>? {
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,
val inLibrary: BooleanFilter? = null,
override val and: List<ChapterFilter>? = null,
override val or: List<ChapterFilter>? = null,
override val not: ChapterFilter? = null
) : Filter<ChapterFilter> {
override fun getOpList(): List<Op<Boolean>> {
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 getLibraryOp() = andFilterWithCompare(MangaTable.inLibrary, inLibrary)
}
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()
val libraryOp = filter?.getLibraryOp()
if (libraryOp != null) {
res.adjustColumnSet {
innerJoin(MangaTable)
}
res.andWhere { libraryOp }
}
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()
)
}
}

View File

@@ -0,0 +1,235 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
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.HasGetOp
import suwayomi.tachidesk.graphql.queries.filter.IntFilter
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.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.ExtensionNodeList
import suwayomi.tachidesk.graphql.types.ExtensionType
import suwayomi.tachidesk.manga.model.table.ExtensionTable
import java.util.concurrent.CompletableFuture
/**
* TODO Queries
*
* TODO Mutations
* - Install
* - Update
* - Uninstall
* - Check for updates (global mutation?)
*/
class ExtensionQuery {
fun extension(dataFetchingEnvironment: DataFetchingEnvironment, pkgName: String): CompletableFuture<ExtensionType?> {
return dataFetchingEnvironment.getValueFromDataLoader("ExtensionDataLoader", pkgName)
}
enum class ExtensionOrderBy(override val column: Column<out Comparable<*>>) : OrderBy<ExtensionType> {
PKG_NAME(ExtensionTable.pkgName),
NAME(ExtensionTable.name),
APK_NAME(ExtensionTable.apkName);
override fun greater(cursor: Cursor): Op<Boolean> {
return when (this) {
PKG_NAME -> ExtensionTable.pkgName greater cursor.value
NAME -> greaterNotUnique(ExtensionTable.name, ExtensionTable.pkgName, cursor, String::toString)
APK_NAME -> greaterNotUnique(ExtensionTable.apkName, ExtensionTable.pkgName, cursor, String::toString)
}
}
override fun less(cursor: Cursor): Op<Boolean> {
return when (this) {
PKG_NAME -> ExtensionTable.pkgName less cursor.value
NAME -> lessNotUnique(ExtensionTable.name, ExtensionTable.pkgName, cursor, String::toString)
APK_NAME -> lessNotUnique(ExtensionTable.apkName, ExtensionTable.pkgName, cursor, String::toString)
}
}
override fun asCursor(type: ExtensionType): Cursor {
val value = when (this) {
PKG_NAME -> type.pkgName
NAME -> type.pkgName + "\\-" + type.name
APK_NAME -> type.pkgName + "\\-" + type.apkName
}
return Cursor(value)
}
}
data class ExtensionCondition(
val apkName: String? = null,
val iconUrl: String? = null,
val name: String? = null,
val pkgName: String? = null,
val versionName: String? = null,
val versionCode: Int? = null,
val lang: String? = null,
val isNsfw: Boolean? = null,
val isInstalled: Boolean? = null,
val hasUpdate: Boolean? = null,
val isObsolete: Boolean? = null
) : HasGetOp {
override fun getOp(): Op<Boolean>? {
val opAnd = OpAnd()
opAnd.eq(apkName, ExtensionTable.apkName)
opAnd.eq(iconUrl, ExtensionTable.iconUrl)
opAnd.eq(name, ExtensionTable.name)
opAnd.eq(versionName, ExtensionTable.versionName)
opAnd.eq(versionCode, ExtensionTable.versionCode)
opAnd.eq(lang, ExtensionTable.lang)
opAnd.eq(isNsfw, ExtensionTable.isNsfw)
opAnd.eq(isInstalled, ExtensionTable.isInstalled)
opAnd.eq(hasUpdate, ExtensionTable.hasUpdate)
opAnd.eq(isObsolete, ExtensionTable.isObsolete)
return opAnd.op
}
}
data class ExtensionFilter(
val apkName: StringFilter? = null,
val iconUrl: StringFilter? = null,
val name: StringFilter? = null,
val pkgName: StringFilter? = null,
val versionName: StringFilter? = null,
val versionCode: IntFilter? = null,
val lang: StringFilter? = null,
val isNsfw: BooleanFilter? = null,
val isInstalled: BooleanFilter? = null,
val hasUpdate: BooleanFilter? = null,
val isObsolete: BooleanFilter? = null,
override val and: List<ExtensionFilter>? = null,
override val or: List<ExtensionFilter>? = null,
override val not: ExtensionFilter? = null
) : Filter<ExtensionFilter> {
override fun getOpList(): List<Op<Boolean>> {
return listOfNotNull(
andFilterWithCompareString(ExtensionTable.apkName, apkName),
andFilterWithCompareString(ExtensionTable.iconUrl, iconUrl),
andFilterWithCompareString(ExtensionTable.name, name),
andFilterWithCompareString(ExtensionTable.pkgName, pkgName),
andFilterWithCompareString(ExtensionTable.versionName, versionName),
andFilterWithCompare(ExtensionTable.versionCode, versionCode),
andFilterWithCompareString(ExtensionTable.lang, lang),
andFilterWithCompare(ExtensionTable.isNsfw, isNsfw),
andFilterWithCompare(ExtensionTable.isInstalled, isInstalled),
andFilterWithCompare(ExtensionTable.hasUpdate, hasUpdate),
andFilterWithCompare(ExtensionTable.isObsolete, isObsolete)
)
}
}
fun extensions(
condition: ExtensionCondition? = null,
filter: ExtensionFilter? = null,
orderBy: ExtensionOrderBy? = null,
orderByType: SortOrder? = null,
before: Cursor? = null,
after: Cursor? = null,
first: Int? = null,
last: Int? = null,
offset: Int? = null
): ExtensionNodeList {
val queryResults = transaction {
val res = ExtensionTable.selectAll()
res.applyOps(condition, filter)
if (orderBy != null || (last != null || before != null)) {
val orderByColumn = orderBy?.column ?: ExtensionTable.pkgName
val orderType = orderByType.maybeSwap(last ?: before)
if (orderBy == ExtensionOrderBy.PKG_NAME || orderBy == null) {
res.orderBy(orderByColumn to orderType)
} else {
res.orderBy(
orderByColumn to orderType,
ExtensionTable.pkgName to SortOrder.ASC
)
}
}
val total = res.count()
val firstResult = res.firstOrNull()?.get(ExtensionTable.pkgName)
val lastResult = res.lastOrNull()?.get(ExtensionTable.pkgName)
if (after != null) {
res.andWhere {
(orderBy ?: ExtensionOrderBy.PKG_NAME).greater(after)
}
} else if (before != null) {
res.andWhere {
(orderBy ?: ExtensionOrderBy.PKG_NAME).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: (ExtensionType) -> Cursor = (orderBy ?: ExtensionOrderBy.PKG_NAME)::asCursor
val resultsAsType = queryResults.results.map { ExtensionType(it) }
return ExtensionNodeList(
resultsAsType,
if (resultsAsType.isEmpty()) {
emptyList()
} else {
listOfNotNull(
resultsAsType.firstOrNull()?.let {
ExtensionNodeList.ExtensionEdge(
getAsCursor(it),
it
)
},
resultsAsType.lastOrNull()?.let {
ExtensionNodeList.ExtensionEdge(
getAsCursor(it),
it
)
}
)
},
pageInfo = PageInfo(
hasNextPage = queryResults.lastKey != resultsAsType.lastOrNull()?.pkgName,
hasPreviousPage = queryResults.firstKey != resultsAsType.firstOrNull()?.pkgName,
startCursor = resultsAsType.firstOrNull()?.let { getAsCursor(it) },
endCursor = resultsAsType.lastOrNull()?.let { getAsCursor(it) }
),
totalCount = queryResults.total.toInt()
)
}
}

View File

@@ -0,0 +1,297 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
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.ComparableScalarFilter
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.greaterNotUnique
import suwayomi.tachidesk.graphql.server.primitives.lessNotUnique
import suwayomi.tachidesk.graphql.server.primitives.maybeSwap
import suwayomi.tachidesk.graphql.types.MangaNodeList
import suwayomi.tachidesk.graphql.types.MangaType
import suwayomi.tachidesk.manga.model.table.CategoryMangaTable
import suwayomi.tachidesk.manga.model.table.MangaStatus
import suwayomi.tachidesk.manga.model.table.MangaTable
import java.util.concurrent.CompletableFuture
/**
* TODO Queries
*/
class MangaQuery {
fun manga(dataFetchingEnvironment: DataFetchingEnvironment, id: Int): CompletableFuture<MangaType?> {
return dataFetchingEnvironment.getValueFromDataLoader("MangaDataLoader", id)
}
enum class MangaOrderBy(override val column: Column<out Comparable<*>>) : OrderBy<MangaType> {
ID(MangaTable.id),
TITLE(MangaTable.title),
IN_LIBRARY_AT(MangaTable.inLibraryAt),
LAST_FETCHED_AT(MangaTable.lastFetchedAt);
override fun greater(cursor: Cursor): Op<Boolean> {
return when (this) {
ID -> MangaTable.id greater cursor.value.toInt()
TITLE -> greaterNotUnique(MangaTable.title, MangaTable.id, cursor, String::toString)
IN_LIBRARY_AT -> greaterNotUnique(MangaTable.inLibraryAt, MangaTable.id, cursor, String::toLong)
LAST_FETCHED_AT -> greaterNotUnique(MangaTable.lastFetchedAt, MangaTable.id, cursor, String::toLong)
}
}
override fun less(cursor: Cursor): Op<Boolean> {
return when (this) {
ID -> MangaTable.id less cursor.value.toInt()
TITLE -> lessNotUnique(MangaTable.title, MangaTable.id, cursor, String::toString)
IN_LIBRARY_AT -> lessNotUnique(MangaTable.inLibraryAt, MangaTable.id, cursor, String::toLong)
LAST_FETCHED_AT -> lessNotUnique(MangaTable.lastFetchedAt, MangaTable.id, cursor, String::toLong)
}
}
override fun asCursor(type: MangaType): Cursor {
val value = when (this) {
ID -> type.id.toString()
TITLE -> type.id.toString() + "-" + type.title
IN_LIBRARY_AT -> type.id.toString() + "-" + type.inLibraryAt.toString()
LAST_FETCHED_AT -> type.id.toString() + "-" + type.lastFetchedAt.toString()
}
return Cursor(value)
}
}
data class MangaCondition(
val id: Int? = null,
val sourceId: Long? = null,
val url: String? = null,
val title: String? = null,
val thumbnailUrl: String? = null,
val initialized: Boolean? = null,
val artist: String? = null,
val author: String? = null,
val description: String? = null,
val genre: List<String>? = null,
val status: MangaStatus? = null,
val inLibrary: Boolean? = null,
val inLibraryAt: Long? = null,
val realUrl: String? = null,
val lastFetchedAt: Long? = null,
val chaptersLastFetchedAt: Long? = null
) : HasGetOp {
override fun getOp(): Op<Boolean>? {
val opAnd = OpAnd()
opAnd.eq(id, MangaTable.id)
opAnd.eq(sourceId, MangaTable.sourceReference)
opAnd.eq(url, MangaTable.url)
opAnd.eq(title, MangaTable.title)
opAnd.eq(thumbnailUrl, MangaTable.thumbnail_url)
opAnd.eq(initialized, MangaTable.initialized)
opAnd.eq(artist, MangaTable.artist)
opAnd.eq(author, MangaTable.author)
opAnd.eq(description, MangaTable.description)
opAnd.eq(genre?.joinToString(), MangaTable.genre)
opAnd.eq(status?.value, MangaTable.status)
opAnd.eq(inLibrary, MangaTable.inLibrary)
opAnd.eq(inLibraryAt, MangaTable.inLibraryAt)
opAnd.eq(realUrl, MangaTable.realUrl)
opAnd.eq(lastFetchedAt, MangaTable.lastFetchedAt)
opAnd.eq(chaptersLastFetchedAt, MangaTable.chaptersLastFetchedAt)
return opAnd.op
}
}
data class MangaStatusFilter(
override val isNull: Boolean? = null,
override val equalTo: MangaStatus? = null,
override val notEqualTo: MangaStatus? = null,
override val distinctFrom: MangaStatus? = null,
override val notDistinctFrom: MangaStatus? = null,
override val `in`: List<MangaStatus>? = null,
override val notIn: List<MangaStatus>? = null,
override val lessThan: MangaStatus? = null,
override val lessThanOrEqualTo: MangaStatus? = null,
override val greaterThan: MangaStatus? = null,
override val greaterThanOrEqualTo: MangaStatus? = null
) : ComparableScalarFilter<MangaStatus> {
fun asIntFilter() = IntFilter(
equalTo = equalTo?.value,
notEqualTo = notEqualTo?.value,
distinctFrom = distinctFrom?.value,
notDistinctFrom = notDistinctFrom?.value,
`in` = `in`?.map { it.value },
notIn = notIn?.map { it.value },
lessThan = lessThan?.value,
lessThanOrEqualTo = lessThanOrEqualTo?.value,
greaterThan = greaterThan?.value,
greaterThanOrEqualTo = greaterThanOrEqualTo?.value
)
}
data class MangaFilter(
val id: IntFilter? = null,
val sourceId: LongFilter? = null,
val url: StringFilter? = null,
val title: StringFilter? = null,
val thumbnailUrl: StringFilter? = null,
val initialized: BooleanFilter? = null,
val artist: StringFilter? = null,
val author: StringFilter? = null,
val description: StringFilter? = null,
// val genre: List<String>? = null, // todo
val status: MangaStatusFilter? = null,
val inLibrary: BooleanFilter? = null,
val inLibraryAt: LongFilter? = null,
val realUrl: StringFilter? = null,
val lastFetchedAt: LongFilter? = null,
val chaptersLastFetchedAt: LongFilter? = null,
val category: IntFilter? = null,
override val and: List<MangaFilter>? = null,
override val or: List<MangaFilter>? = null,
override val not: MangaFilter? = null
) : Filter<MangaFilter> {
override fun getOpList(): List<Op<Boolean>> {
return listOfNotNull(
andFilterWithCompareEntity(MangaTable.id, id),
andFilterWithCompare(MangaTable.sourceReference, sourceId),
andFilterWithCompareString(MangaTable.url, url),
andFilterWithCompareString(MangaTable.title, title),
andFilterWithCompareString(MangaTable.thumbnail_url, thumbnailUrl),
andFilterWithCompare(MangaTable.initialized, initialized),
andFilterWithCompareString(MangaTable.artist, artist),
andFilterWithCompareString(MangaTable.author, author),
andFilterWithCompareString(MangaTable.description, description),
andFilterWithCompare(MangaTable.status, status?.asIntFilter()),
andFilterWithCompare(MangaTable.inLibrary, inLibrary),
andFilterWithCompare(MangaTable.inLibraryAt, inLibraryAt),
andFilterWithCompareString(MangaTable.realUrl, realUrl),
andFilterWithCompare(MangaTable.inLibraryAt, lastFetchedAt),
andFilterWithCompare(MangaTable.inLibraryAt, chaptersLastFetchedAt)
)
}
fun getCategoryOp() = andFilterWithCompareEntity(CategoryMangaTable.category, category)
}
fun mangas(
condition: MangaCondition? = null,
filter: MangaFilter? = null,
orderBy: MangaOrderBy? = null,
orderByType: SortOrder? = null,
before: Cursor? = null,
after: Cursor? = null,
first: Int? = null,
last: Int? = null,
offset: Int? = null
): MangaNodeList {
val queryResults = transaction {
val res = MangaTable.selectAll()
val categoryOp = filter?.getCategoryOp()
if (categoryOp != null) {
res.adjustColumnSet {
innerJoin(CategoryMangaTable)
}
res.andWhere { categoryOp }
}
res.applyOps(condition, filter)
if (orderBy != null || (last != null || before != null)) {
val orderByColumn = orderBy?.column ?: MangaTable.id
val orderType = orderByType.maybeSwap(last ?: before)
if (orderBy == MangaOrderBy.ID || orderBy == null) {
res.orderBy(orderByColumn to orderType)
} else {
res.orderBy(
orderByColumn to orderType,
MangaTable.id to SortOrder.ASC
)
}
}
val total = res.count()
val firstResult = res.firstOrNull()?.get(MangaTable.id)?.value
val lastResult = res.lastOrNull()?.get(MangaTable.id)?.value
if (after != null) {
res.andWhere {
(orderBy ?: MangaOrderBy.ID).greater(after)
}
} else if (before != null) {
res.andWhere {
(orderBy ?: MangaOrderBy.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: (MangaType) -> Cursor = (orderBy ?: MangaOrderBy.ID)::asCursor
val resultsAsType = queryResults.results.map { MangaType(it) }
return MangaNodeList(
resultsAsType,
if (resultsAsType.isEmpty()) {
emptyList()
} else {
listOfNotNull(
resultsAsType.firstOrNull()?.let {
MangaNodeList.MangaEdge(
getAsCursor(it),
it
)
},
resultsAsType.lastOrNull()?.let {
MangaNodeList.MangaEdge(
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()
)
}
}

View File

@@ -0,0 +1,193 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
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.global.model.table.GlobalMetaTable
import suwayomi.tachidesk.graphql.queries.filter.Filter
import suwayomi.tachidesk.graphql.queries.filter.HasGetOp
import suwayomi.tachidesk.graphql.queries.filter.OpAnd
import suwayomi.tachidesk.graphql.queries.filter.StringFilter
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.GlobalMetaItem
import suwayomi.tachidesk.graphql.types.MetaItem
import suwayomi.tachidesk.graphql.types.MetaNodeList
import java.util.concurrent.CompletableFuture
/**
* TODO Queries
*
* TODO Mutations
* - Add/update meta
* - Delete meta
*
*/
class MetaQuery {
fun meta(dataFetchingEnvironment: DataFetchingEnvironment, key: String): CompletableFuture<MetaItem?> {
return dataFetchingEnvironment.getValueFromDataLoader<String, MetaItem?>("GlobalMetaDataLoader", key)
}
enum class MetaOrderBy(override val column: Column<out Comparable<*>>) : OrderBy<MetaItem> {
KEY(GlobalMetaTable.key),
VALUE(GlobalMetaTable.value);
override fun greater(cursor: Cursor): Op<Boolean> {
return when (this) {
KEY -> GlobalMetaTable.key greater cursor.value
VALUE -> greaterNotUnique(GlobalMetaTable.value, GlobalMetaTable.key, cursor, String::toString)
}
}
override fun less(cursor: Cursor): Op<Boolean> {
return when (this) {
KEY -> GlobalMetaTable.key less cursor.value
VALUE -> lessNotUnique(GlobalMetaTable.value, GlobalMetaTable.key, cursor, String::toString)
}
}
override fun asCursor(type: MetaItem): Cursor {
val value = when (this) {
KEY -> type.key
VALUE -> type.key + "\\-" + type.value
}
return Cursor(value)
}
}
data class MetaCondition(
val key: String? = null,
val value: String? = null
) : HasGetOp {
override fun getOp(): Op<Boolean>? {
val opAnd = OpAnd()
opAnd.eq(key, GlobalMetaTable.key)
opAnd.eq(value, GlobalMetaTable.value)
return opAnd.op
}
}
data class MetaFilter(
val key: StringFilter? = null,
val value: StringFilter? = null,
override val and: List<MetaFilter>? = null,
override val or: List<MetaFilter>? = null,
override val not: MetaFilter? = null
) : Filter<MetaFilter> {
override fun getOpList(): List<Op<Boolean>> {
return listOfNotNull(
andFilterWithCompareString(GlobalMetaTable.key, key),
andFilterWithCompareString(GlobalMetaTable.value, value)
)
}
}
fun metas(
condition: MetaCondition? = null,
filter: MetaFilter? = null,
orderBy: MetaOrderBy? = null,
orderByType: SortOrder? = null,
before: Cursor? = null,
after: Cursor? = null,
first: Int? = null,
last: Int? = null,
offset: Int? = null
): MetaNodeList {
val queryResults = transaction {
val res = GlobalMetaTable.selectAll()
res.applyOps(condition, filter)
if (orderBy != null || (last != null || before != null)) {
val orderByColumn = orderBy?.column ?: GlobalMetaTable.key
val orderType = orderByType.maybeSwap(last ?: before)
if (orderBy == MetaOrderBy.KEY || orderBy == null) {
res.orderBy(orderByColumn to orderType)
} else {
res.orderBy(
orderByColumn to orderType,
GlobalMetaTable.key to SortOrder.ASC
)
}
}
val total = res.count()
val firstResult = res.firstOrNull()?.get(GlobalMetaTable.key)
val lastResult = res.lastOrNull()?.get(GlobalMetaTable.key)
if (after != null) {
res.andWhere {
(orderBy ?: MetaOrderBy.KEY).greater(after)
}
} else if (before != null) {
res.andWhere {
(orderBy ?: MetaOrderBy.KEY).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: (MetaItem) -> Cursor = (orderBy ?: MetaOrderBy.KEY)::asCursor
val resultsAsType = queryResults.results.map { GlobalMetaItem(it) }
return MetaNodeList(
resultsAsType,
if (resultsAsType.isEmpty()) {
emptyList()
} else {
listOfNotNull(
resultsAsType.firstOrNull()?.let {
MetaNodeList.MetaEdge(
getAsCursor(it),
it
)
},
resultsAsType.lastOrNull()?.let {
MetaNodeList.MetaEdge(
getAsCursor(it),
it
)
}
)
},
pageInfo = PageInfo(
hasNextPage = queryResults.lastKey != resultsAsType.lastOrNull()?.key,
hasPreviousPage = queryResults.firstKey != resultsAsType.firstOrNull()?.key,
startCursor = resultsAsType.firstOrNull()?.let { getAsCursor(it) },
endCursor = resultsAsType.lastOrNull()?.let { getAsCursor(it) }
),
totalCount = queryResults.total.toInt()
)
}
}

View File

@@ -0,0 +1,208 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
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.HasGetOp
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.SourceNodeList
import suwayomi.tachidesk.graphql.types.SourceType
import suwayomi.tachidesk.manga.model.table.SourceTable
import java.util.concurrent.CompletableFuture
/**
* TODO Queries
*
* TODO Mutations
* - Browse with filters
* - Configure settings
*
*/
class SourceQuery {
fun source(dataFetchingEnvironment: DataFetchingEnvironment, id: Long): CompletableFuture<SourceType?> {
return dataFetchingEnvironment.getValueFromDataLoader<Long, SourceType?>("SourceDataLoader", id)
}
enum class SourceOrderBy(override val column: Column<out Comparable<*>>) : OrderBy<SourceType> {
ID(SourceTable.id),
NAME(SourceTable.name),
LANG(SourceTable.lang);
override fun greater(cursor: Cursor): Op<Boolean> {
return when (this) {
ID -> SourceTable.id greater cursor.value.toLong()
NAME -> greaterNotUnique(SourceTable.name, SourceTable.id, cursor, String::toString)
LANG -> greaterNotUnique(SourceTable.lang, SourceTable.id, cursor, String::toString)
}
}
override fun less(cursor: Cursor): Op<Boolean> {
return when (this) {
ID -> SourceTable.id less cursor.value.toLong()
NAME -> lessNotUnique(SourceTable.name, SourceTable.id, cursor, String::toString)
LANG -> lessNotUnique(SourceTable.lang, SourceTable.id, cursor, String::toString)
}
}
override fun asCursor(type: SourceType): Cursor {
val value = when (this) {
ID -> type.id.toString()
NAME -> type.id.toString() + "-" + type.name
LANG -> type.id.toString() + "-" + type.lang
}
return Cursor(value)
}
}
data class SourceCondition(
val id: Long? = null,
val name: String? = null,
val lang: String? = null,
val isNsfw: Boolean? = null
) : HasGetOp {
override fun getOp(): Op<Boolean>? {
val opAnd = OpAnd()
opAnd.eq(id, SourceTable.id)
opAnd.eq(name, SourceTable.name)
opAnd.eq(lang, SourceTable.lang)
opAnd.eq(isNsfw, SourceTable.isNsfw)
return opAnd.op
}
}
data class SourceFilter(
val id: LongFilter? = null,
val name: StringFilter? = null,
val lang: StringFilter? = null,
val isNsfw: BooleanFilter? = null,
override val and: List<SourceFilter>? = null,
override val or: List<SourceFilter>? = null,
override val not: SourceFilter? = null
) : Filter<SourceFilter> {
override fun getOpList(): List<Op<Boolean>> {
return listOfNotNull(
andFilterWithCompareEntity(SourceTable.id, id),
andFilterWithCompareString(SourceTable.name, name),
andFilterWithCompareString(SourceTable.lang, lang),
andFilterWithCompare(SourceTable.isNsfw, isNsfw)
)
}
}
fun sources(
condition: SourceCondition? = null,
filter: SourceFilter? = null,
orderBy: SourceOrderBy? = null,
orderByType: SortOrder? = null,
before: Cursor? = null,
after: Cursor? = null,
first: Int? = null,
last: Int? = null,
offset: Int? = null
): SourceNodeList {
val (queryResults, resultsAsType) = transaction {
val res = SourceTable.selectAll()
res.applyOps(condition, filter)
if (orderBy != null || (last != null || before != null)) {
val orderByColumn = orderBy?.column ?: SourceTable.id
val orderType = orderByType.maybeSwap(last ?: before)
if (orderBy == SourceOrderBy.ID || orderBy == null) {
res.orderBy(orderByColumn to orderType)
} else {
res.orderBy(
orderByColumn to orderType,
SourceTable.id to SortOrder.ASC
)
}
}
val total = res.count()
val firstResult = res.firstOrNull()?.get(SourceTable.id)?.value
val lastResult = res.lastOrNull()?.get(SourceTable.id)?.value
if (after != null) {
res.andWhere {
(orderBy ?: SourceOrderBy.ID).greater(after)
}
} else if (before != null) {
res.andWhere {
(orderBy ?: SourceOrderBy.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()).let {
it to it.results.mapNotNull { SourceType(it) }
}
}
val getAsCursor: (SourceType) -> Cursor = (orderBy ?: SourceOrderBy.ID)::asCursor
return SourceNodeList(
resultsAsType,
if (resultsAsType.isEmpty()) {
emptyList()
} else {
listOfNotNull(
resultsAsType.firstOrNull()?.let {
SourceNodeList.SourceEdge(
getAsCursor(it),
it
)
},
resultsAsType.lastOrNull()?.let {
SourceNodeList.SourceEdge(
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()
)
}
}

View File

@@ -0,0 +1,379 @@
package suwayomi.tachidesk.graphql.queries.filter
import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.sql.Column
import org.jetbrains.exposed.sql.ComparisonOp
import org.jetbrains.exposed.sql.Expression
import org.jetbrains.exposed.sql.ExpressionWithColumnType
import org.jetbrains.exposed.sql.LikePattern
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.and
import org.jetbrains.exposed.sql.andWhere
import org.jetbrains.exposed.sql.not
import org.jetbrains.exposed.sql.or
import org.jetbrains.exposed.sql.stringParam
import org.jetbrains.exposed.sql.upperCase
class ILikeEscapeOp(expr1: Expression<*>, expr2: Expression<*>, like: Boolean, val escapeChar: Char?) : ComparisonOp(expr1, expr2, if (like) "ILIKE" else "NOT ILIKE") {
override fun toQueryBuilder(queryBuilder: QueryBuilder) {
super.toQueryBuilder(queryBuilder)
if (escapeChar != null) {
with(queryBuilder) {
+" ESCAPE "
+stringParam(escapeChar.toString())
}
}
}
companion object {
fun <T : String?> iLike(expression: Expression<T>, pattern: String): ILikeEscapeOp = iLike(expression, LikePattern(pattern))
fun <T : String?> iNotLike(expression: Expression<T>, pattern: String): ILikeEscapeOp = iNotLike(expression, LikePattern(pattern))
fun <T : String?> iLike(expression: Expression<T>, pattern: LikePattern): ILikeEscapeOp = ILikeEscapeOp(expression, stringParam(pattern.pattern), true, pattern.escapeChar)
fun <T : String?> iNotLike(expression: Expression<T>, pattern: LikePattern): ILikeEscapeOp = ILikeEscapeOp(expression, stringParam(pattern.pattern), false, pattern.escapeChar)
}
}
class DistinctFromOp(expr1: Expression<*>, expr2: Expression<*>, not: Boolean) : ComparisonOp(expr1, expr2, if (not) "IS NOT DISTINCT FROM" else "IS DISTINCT FROM") {
companion object {
fun <T> distinctFrom(expression: ExpressionWithColumnType<T>, t: T): DistinctFromOp = DistinctFromOp(
expression,
with(SqlExpressionBuilder) {
expression.wrap(t)
},
false
)
fun <T> notDistinctFrom(expression: ExpressionWithColumnType<T>, t: T): DistinctFromOp = DistinctFromOp(
expression,
with(SqlExpressionBuilder) {
expression.wrap(t)
},
true
)
fun <T : Comparable<T>> distinctFrom(expression: ExpressionWithColumnType<EntityID<T>>, t: T): DistinctFromOp = DistinctFromOp(
expression,
with(SqlExpressionBuilder) {
expression.wrap(t)
},
false
)
fun <T : Comparable<T>> notDistinctFrom(expression: ExpressionWithColumnType<EntityID<T>>, t: T): DistinctFromOp = DistinctFromOp(
expression,
with(SqlExpressionBuilder) {
expression.wrap(t)
},
true
)
}
}
interface HasGetOp {
fun getOp(): Op<Boolean>?
}
fun Query.applyOps(vararg ops: HasGetOp?) {
ops.mapNotNull { it?.getOp() }.forEach {
andWhere { it }
}
}
interface Filter<T : Filter<T>> : HasGetOp {
val and: List<T>?
val or: List<T>?
val not: T?
fun getOpList(): List<Op<Boolean>>
override fun getOp(): Op<Boolean>? {
var op: Op<Boolean>? = null
fun newOp(
otherOp: Op<Boolean>?,
operator: (Op<Boolean>, Op<Boolean>) -> Op<Boolean>
) {
when {
op == null && otherOp == null -> Unit
op == null && otherOp != null -> op = otherOp
op != null && otherOp == null -> Unit
op != null && otherOp != null -> op = operator(op!!, otherOp)
}
}
fun andOp(andOp: Op<Boolean>?) {
newOp(andOp, Op<Boolean>::and)
}
fun orOp(orOp: Op<Boolean>?) {
newOp(orOp, Op<Boolean>::or)
}
getOpList().forEach {
andOp(it)
}
and?.forEach {
andOp(it.getOp())
}
or?.forEach {
orOp(it.getOp())
}
if (not != null) {
andOp(not!!.getOp()?.let(::not))
}
return op
}
}
interface ScalarFilter<T> {
val isNull: Boolean?
val equalTo: T?
val notEqualTo: T?
val distinctFrom: T?
val notDistinctFrom: T?
val `in`: List<T>?
val notIn: List<T>?
}
interface ComparableScalarFilter<T : Comparable<T>> : ScalarFilter<T> {
val lessThan: T?
val lessThanOrEqualTo: T?
val greaterThan: T?
val greaterThanOrEqualTo: T?
}
interface ListScalarFilter<T, R : List<T>> : ScalarFilter<T> {
val hasAny: List<T>?
val hasAll: List<T>?
val hasNone: List<T>?
}
data class LongFilter(
override val isNull: Boolean? = null,
override val equalTo: Long? = null,
override val notEqualTo: Long? = null,
override val distinctFrom: Long? = null,
override val notDistinctFrom: Long? = null,
override val `in`: List<Long>? = null,
override val notIn: List<Long>? = null,
override val lessThan: Long? = null,
override val lessThanOrEqualTo: Long? = null,
override val greaterThan: Long? = null,
override val greaterThanOrEqualTo: Long? = null
) : ComparableScalarFilter<Long>
data class BooleanFilter(
override val isNull: Boolean? = null,
override val equalTo: Boolean? = null,
override val notEqualTo: Boolean? = null,
override val distinctFrom: Boolean? = null,
override val notDistinctFrom: Boolean? = null,
override val `in`: List<Boolean>? = null,
override val notIn: List<Boolean>? = null,
override val lessThan: Boolean? = null,
override val lessThanOrEqualTo: Boolean? = null,
override val greaterThan: Boolean? = null,
override val greaterThanOrEqualTo: Boolean? = null
) : ComparableScalarFilter<Boolean>
data class IntFilter(
override val isNull: Boolean? = null,
override val equalTo: Int? = null,
override val notEqualTo: Int? = null,
override val distinctFrom: Int? = null,
override val notDistinctFrom: Int? = null,
override val `in`: List<Int>? = null,
override val notIn: List<Int>? = null,
override val lessThan: Int? = null,
override val lessThanOrEqualTo: Int? = null,
override val greaterThan: Int? = null,
override val greaterThanOrEqualTo: Int? = null
) : ComparableScalarFilter<Int>
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<Float>? = null,
override val notIn: List<Float>? = null,
override val lessThan: Float? = null,
override val lessThanOrEqualTo: Float? = null,
override val greaterThan: Float? = null,
override val greaterThanOrEqualTo: Float? = null
) : ComparableScalarFilter<Float>
data class StringFilter(
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<String>? = null,
override val notIn: List<String>? = null,
override val lessThan: String? = null,
override val lessThanOrEqualTo: String? = null,
override val greaterThan: String? = null,
override val greaterThanOrEqualTo: String? = null,
val includes: String? = null,
val notIncludes: String? = null,
val includesInsensitive: String? = null,
val notIncludesInsensitive: String? = null,
val startsWith: String? = null,
val notStartsWith: String? = null,
val startsWithInsensitive: String? = null,
val notStartsWithInsensitive: String? = null,
val endsWith: String? = null,
val notEndsWith: String? = null,
val endsWithInsensitive: String? = null,
val notEndsWithInsensitive: String? = null,
val like: String? = null,
val notLike: String? = null,
val likeInsensitive: String? = null,
val notLikeInsensitive: String? = null,
val distinctFromInsensitive: String? = null,
val notDistinctFromInsensitive: String? = null,
val inInsensitive: List<String>? = null,
val notInInsensitive: List<String>? = null,
val lessThanInsensitive: String? = null,
val lessThanOrEqualToInsensitive: String? = null,
val greaterThanInsensitive: String? = null,
val greaterThanOrEqualToInsensitive: String? = null
) : ComparableScalarFilter<String>
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<String>? = null,
override val notIn: List<String>? = null,
override val hasAny: List<String>? = null,
override val hasAll: List<String>? = null,
override val hasNone: List<String>? = null,
val hasAnyInsensitive: List<String>? = null,
val hasAllInsensitive: List<String>? = null,
val hasNoneInsensitive: List<String>? = null
) : ListScalarFilter<String, List<String>>
@Suppress("UNCHECKED_CAST")
fun <T : String?> andFilterWithCompareString(
column: Column<T>,
filter: StringFilter?
): Op<Boolean>? {
filter ?: return null
val opAnd = OpAnd()
opAnd.andWhere(filter.includes) { column like "%$it%" }
opAnd.andWhere(filter.notIncludes) { column notLike "%$it%" }
opAnd.andWhere(filter.includesInsensitive) { ILikeEscapeOp.iLike(column, "%$it%") }
opAnd.andWhere(filter.notIncludesInsensitive) { ILikeEscapeOp.iNotLike(column, "%$it%") }
opAnd.andWhere(filter.startsWith) { column like "$it%" }
opAnd.andWhere(filter.notStartsWith) { column notLike "$it%" }
opAnd.andWhere(filter.startsWithInsensitive) { ILikeEscapeOp.iLike(column, "$it%") }
opAnd.andWhere(filter.notStartsWithInsensitive) { ILikeEscapeOp.iNotLike(column, "$it%") }
opAnd.andWhere(filter.endsWith) { column like "%$it" }
opAnd.andWhere(filter.notEndsWith) { column notLike "%$it" }
opAnd.andWhere(filter.endsWithInsensitive) { ILikeEscapeOp.iLike(column, "%$it") }
opAnd.andWhere(filter.notEndsWithInsensitive) { ILikeEscapeOp.iNotLike(column, "%$it") }
opAnd.andWhere(filter.like) { column like it }
opAnd.andWhere(filter.notLike) { column notLike it }
opAnd.andWhere(filter.likeInsensitive) { ILikeEscapeOp.iLike(column, it) }
opAnd.andWhere(filter.notLikeInsensitive) { ILikeEscapeOp.iNotLike(column, it) }
opAnd.andWhere(filter.distinctFromInsensitive) { DistinctFromOp.distinctFrom(column.upperCase(), it.uppercase() as T) }
opAnd.andWhere(filter.notDistinctFromInsensitive) { DistinctFromOp.notDistinctFrom(column.upperCase(), it.uppercase() as T) }
opAnd.andWhere(filter.inInsensitive) { column.upperCase() inList (it.map { it.uppercase() } as List<T>) }
opAnd.andWhere(filter.notInInsensitive) { column.upperCase() notInList (it.map { it.uppercase() } as List<T>) }
opAnd.andWhere(filter.lessThanInsensitive) { column.upperCase() less it.uppercase() }
opAnd.andWhere(filter.lessThanOrEqualToInsensitive) { column.upperCase() lessEq it.uppercase() }
opAnd.andWhere(filter.greaterThanInsensitive) { column.upperCase() greater it.uppercase() }
opAnd.andWhere(filter.greaterThanOrEqualToInsensitive) { column.upperCase() greaterEq it.uppercase() }
return opAnd.op
}
class OpAnd(var op: Op<Boolean>? = null) {
fun <T> andWhere(value: T?, andPart: SqlExpressionBuilder.(T) -> Op<Boolean>) {
value ?: return
val expr = Op.build { andPart(value) }
op = if (op == null) expr else (op!! and expr)
}
fun <T> eq(value: T?, column: Column<T>) = andWhere(value) { column eq it }
fun <T : Comparable<T>> eq(value: T?, column: Column<EntityID<T>>) = andWhere(value) { column eq it }
}
fun <T : Comparable<T>> andFilterWithCompare(
column: Column<T>,
filter: ComparableScalarFilter<T>?
): Op<Boolean>? {
filter ?: return null
val opAnd = OpAnd(andFilter(column, filter))
opAnd.andWhere(filter.lessThan) { column less it }
opAnd.andWhere(filter.lessThanOrEqualTo) { column lessEq it }
opAnd.andWhere(filter.greaterThan) { column greater it }
opAnd.andWhere(filter.greaterThanOrEqualTo) { column greaterEq it }
return opAnd.op
}
fun <T : Comparable<T>> andFilterWithCompareEntity(
column: Column<EntityID<T>>,
filter: ComparableScalarFilter<T>?
): Op<Boolean>? {
filter ?: return null
val opAnd = OpAnd(andFilterEntity(column, filter))
opAnd.andWhere(filter.lessThan) { column less it }
opAnd.andWhere(filter.lessThanOrEqualTo) { column lessEq it }
opAnd.andWhere(filter.greaterThan) { column greater it }
opAnd.andWhere(filter.greaterThanOrEqualTo) { column greaterEq it }
return opAnd.op
}
fun <T : Comparable<T>> andFilter(
column: Column<T>,
filter: ScalarFilter<T>?
): Op<Boolean>? {
filter ?: return null
val opAnd = OpAnd()
opAnd.andWhere(filter.isNull) { if (it) column.isNull() else column.isNotNull() }
opAnd.andWhere(filter.equalTo) { column eq it }
opAnd.andWhere(filter.notEqualTo) { column neq it }
opAnd.andWhere(filter.distinctFrom) { DistinctFromOp.distinctFrom(column, it) }
opAnd.andWhere(filter.notDistinctFrom) { DistinctFromOp.notDistinctFrom(column, it) }
if (!filter.`in`.isNullOrEmpty()) {
opAnd.andWhere(filter.`in`) { column inList it }
}
if (!filter.notIn.isNullOrEmpty()) {
opAnd.andWhere(filter.notIn) { column notInList it }
}
return opAnd.op
}
fun <T : Comparable<T>> andFilterEntity(
column: Column<EntityID<T>>,
filter: ScalarFilter<T>?
): Op<Boolean>? {
filter ?: return null
val opAnd = OpAnd()
opAnd.andWhere(filter.isNull) { if (filter.isNull!!) column.isNull() else column.isNotNull() }
opAnd.andWhere(filter.equalTo) { column eq filter.equalTo!! }
opAnd.andWhere(filter.notEqualTo) { column neq filter.notEqualTo!! }
opAnd.andWhere(filter.distinctFrom) { DistinctFromOp.distinctFrom(column, it) }
opAnd.andWhere(filter.notDistinctFrom) { DistinctFromOp.notDistinctFrom(column, it) }
if (!filter.`in`.isNullOrEmpty()) {
opAnd.andWhere(filter.`in`) { column inList filter.`in`!! }
}
if (!filter.notIn.isNullOrEmpty()) {
opAnd.andWhere(filter.notIn) { column notInList filter.notIn!! }
}
return opAnd.op
}

View File

@@ -0,0 +1,23 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
package suwayomi.tachidesk.graphql.server
import com.expediagroup.graphql.server.execution.GraphQLRequestParser
import com.expediagroup.graphql.server.types.GraphQLServerRequest
import io.javalin.http.Context
import java.io.IOException
class JavalinGraphQLRequestParser : GraphQLRequestParser<Context> {
@Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE")
override suspend fun parseRequest(context: Context): GraphQLServerRequest? = try {
context.bodyAsClass(GraphQLServerRequest::class.java)
} catch (e: IOException) {
null
}
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
package suwayomi.tachidesk.graphql.server
import com.expediagroup.graphql.dataloader.KotlinDataLoaderRegistryFactory
import suwayomi.tachidesk.graphql.dataLoaders.CategoriesForMangaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.CategoryMetaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.ChapterDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.ChapterMetaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.ChaptersForMangaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.ExtensionDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.ExtensionForSourceDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.GlobalMetaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.MangaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.MangaForCategoryDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.MangaMetaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.SourceDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.SourceForMangaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.SourcesForExtensionDataLoader
class TachideskDataLoaderRegistryFactory {
companion object {
fun create(): KotlinDataLoaderRegistryFactory {
return KotlinDataLoaderRegistryFactory(
MangaDataLoader(),
ChapterDataLoader(),
ChaptersForMangaDataLoader(),
GlobalMetaDataLoader(),
ChapterMetaDataLoader(),
MangaMetaDataLoader(),
MangaForCategoryDataLoader(),
CategoryMetaDataLoader(),
CategoriesForMangaDataLoader(),
SourceDataLoader(),
SourceForMangaDataLoader(),
SourcesForExtensionDataLoader(),
ExtensionDataLoader(),
ExtensionForSourceDataLoader()
)
}
}
}

View File

@@ -0,0 +1,41 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
package suwayomi.tachidesk.graphql.server
import com.expediagroup.graphql.generator.execution.GraphQLContext
import com.expediagroup.graphql.server.execution.GraphQLContextFactory
import io.javalin.http.Context
import io.javalin.websocket.WsContext
/**
* Custom logic for how Tachidesk should create its context given the [Context]
*/
class TachideskGraphQLContextFactory : GraphQLContextFactory<GraphQLContext, Context> {
override suspend fun generateContextMap(request: Context): Map<*, Any> = emptyMap<Any, Any>()
// mutableMapOf<Any, Any>(
// "user" to User(
// email = "fake@site.com",
// firstName = "Someone",
// lastName = "You Don't know",
// universityId = 4
// )
// ).also { map ->
// request.headers["my-custom-header"]?.let { customHeader ->
// map["customHeader"] = customHeader
// }
// }
fun generateContextMap(request: WsContext): Map<*, Any> = emptyMap<Any, Any>()
}
/**
* Create a [GraphQLContext] from [this] map
* @return a new [GraphQLContext]
*/
fun Map<*, Any?>.toGraphQLContext(): graphql.GraphQLContext =
graphql.GraphQLContext.of(this)

View File

@@ -0,0 +1,59 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
package suwayomi.tachidesk.graphql.server
import com.expediagroup.graphql.generator.SchemaGeneratorConfig
import com.expediagroup.graphql.generator.TopLevelObject
import com.expediagroup.graphql.generator.hooks.FlowSubscriptionSchemaGeneratorHooks
import com.expediagroup.graphql.generator.toSchema
import graphql.schema.GraphQLType
import suwayomi.tachidesk.graphql.mutations.ChapterMutation
import suwayomi.tachidesk.graphql.mutations.MangaMutation
import suwayomi.tachidesk.graphql.queries.CategoryQuery
import suwayomi.tachidesk.graphql.queries.ChapterQuery
import suwayomi.tachidesk.graphql.queries.ExtensionQuery
import suwayomi.tachidesk.graphql.queries.MangaQuery
import suwayomi.tachidesk.graphql.queries.MetaQuery
import suwayomi.tachidesk.graphql.queries.SourceQuery
import suwayomi.tachidesk.graphql.server.primitives.Cursor
import suwayomi.tachidesk.graphql.server.primitives.GraphQLCursor
import suwayomi.tachidesk.graphql.server.primitives.GraphQLLongAsString
import suwayomi.tachidesk.graphql.subscriptions.DownloadSubscription
import kotlin.reflect.KClass
import kotlin.reflect.KType
class CustomSchemaGeneratorHooks : FlowSubscriptionSchemaGeneratorHooks() {
override fun willGenerateGraphQLType(type: KType): GraphQLType? = when (type.classifier as? KClass<*>) {
Long::class -> GraphQLLongAsString // encode to string for JS
Cursor::class -> GraphQLCursor
else -> super.willGenerateGraphQLType(type)
}
}
val schema = toSchema(
config = SchemaGeneratorConfig(
supportedPackages = listOf("suwayomi.tachidesk.graphql"),
introspectionEnabled = true,
hooks = CustomSchemaGeneratorHooks()
),
queries = listOf(
TopLevelObject(MangaQuery()),
TopLevelObject(ChapterQuery()),
TopLevelObject(CategoryQuery()),
TopLevelObject(SourceQuery()),
TopLevelObject(ExtensionQuery()),
TopLevelObject(MetaQuery())
),
mutations = listOf(
TopLevelObject(ChapterMutation()),
TopLevelObject(MangaMutation())
),
subscriptions = listOf(
TopLevelObject(DownloadSubscription())
)
)

View File

@@ -0,0 +1,60 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
package suwayomi.tachidesk.graphql.server
import com.expediagroup.graphql.generator.execution.FlowSubscriptionExecutionStrategy
import com.expediagroup.graphql.server.execution.GraphQLRequestHandler
import com.expediagroup.graphql.server.execution.GraphQLServer
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import graphql.GraphQL
import io.javalin.http.Context
import io.javalin.websocket.WsCloseContext
import io.javalin.websocket.WsMessageContext
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import suwayomi.tachidesk.graphql.server.subscriptions.ApolloSubscriptionProtocolHandler
import suwayomi.tachidesk.graphql.server.subscriptions.GraphQLSubscriptionHandler
class TachideskGraphQLServer(
requestParser: JavalinGraphQLRequestParser,
contextFactory: TachideskGraphQLContextFactory,
requestHandler: GraphQLRequestHandler,
subscriptionHandler: GraphQLSubscriptionHandler
) : GraphQLServer<Context>(requestParser, contextFactory, requestHandler) {
private val objectMapper = jacksonObjectMapper()
private val subscriptionProtocolHandler = ApolloSubscriptionProtocolHandler(contextFactory, subscriptionHandler, objectMapper)
fun handleSubscriptionMessage(context: WsMessageContext) {
subscriptionProtocolHandler.handleMessage(context)
.map { objectMapper.writeValueAsString(it) }
.map { context.send(it) }
.launchIn(GlobalScope)
}
fun handleSubscriptionDisconnect(context: WsCloseContext) {
subscriptionProtocolHandler.handleDisconnect(context)
}
companion object {
private fun getGraphQLObject(): GraphQL = GraphQL.newGraphQL(schema)
.subscriptionExecutionStrategy(FlowSubscriptionExecutionStrategy())
.build()
fun create(): TachideskGraphQLServer {
val graphQL = getGraphQLObject()
val requestParser = JavalinGraphQLRequestParser()
val contextFactory = TachideskGraphQLContextFactory()
val requestHandler = GraphQLRequestHandler(graphQL, TachideskDataLoaderRegistryFactory.create())
val subscriptionHandler = GraphQLSubscriptionHandler(graphQL, TachideskDataLoaderRegistryFactory.create())
return TachideskGraphQLServer(requestParser, contextFactory, requestHandler, subscriptionHandler)
}
}
}

View File

@@ -0,0 +1,119 @@
package suwayomi.tachidesk.graphql.server.primitives
import graphql.GraphQLContext
import graphql.execution.CoercedVariables
import graphql.language.StringValue
import graphql.language.Value
import graphql.scalar.CoercingUtil
import graphql.schema.Coercing
import graphql.schema.CoercingParseLiteralException
import graphql.schema.CoercingParseValueException
import graphql.schema.CoercingSerializeException
import graphql.schema.GraphQLScalarType
import java.util.Locale
data class Cursor(val value: String)
val GraphQLCursor: GraphQLScalarType = GraphQLScalarType.newScalar()
.name("Cursor").description("A location in a connection that can be used for resuming pagination.").coercing(GraphqlCursorCoercing()).build()
private class GraphqlCursorCoercing : Coercing<Cursor, String> {
private fun toStringImpl(input: Any): String? {
return (input as? Cursor)?.value
}
private fun parseValueImpl(input: Any, locale: Locale): Cursor {
if (input !is String) {
throw CoercingParseValueException(
CoercingUtil.i18nMsg(
locale,
"String.unexpectedRawValueType",
CoercingUtil.typeName(input)
)
)
}
return Cursor(input)
}
private fun parseLiteralImpl(input: Any, locale: Locale): Cursor {
if (input !is StringValue) {
throw CoercingParseLiteralException(
CoercingUtil.i18nMsg(
locale,
"Scalar.unexpectedAstType",
"StringValue",
CoercingUtil.typeName(input)
)
)
}
return Cursor(input.value)
}
private fun valueToLiteralImpl(input: Any): StringValue {
return StringValue.newStringValue(input.toString()).build()
}
@Deprecated("")
override fun serialize(dataFetcherResult: Any): String {
return toStringImpl(dataFetcherResult) ?: throw CoercingSerializeException(
CoercingUtil.i18nMsg(
Locale.getDefault(),
"String.unexpectedRawValueType",
CoercingUtil.typeName(dataFetcherResult)
)
)
}
@Throws(CoercingSerializeException::class)
override fun serialize(
dataFetcherResult: Any,
graphQLContext: GraphQLContext,
locale: Locale
): String {
return toStringImpl(dataFetcherResult) ?: throw CoercingSerializeException(
CoercingUtil.i18nMsg(
locale,
"String.unexpectedRawValueType",
CoercingUtil.typeName(dataFetcherResult)
)
)
}
@Deprecated("")
override fun parseValue(input: Any): Cursor {
return parseValueImpl(input, Locale.getDefault())
}
@Throws(CoercingParseValueException::class)
override fun parseValue(input: Any, graphQLContext: GraphQLContext, locale: Locale): Cursor {
return parseValueImpl(input, locale)
}
@Deprecated("")
override fun parseLiteral(input: Any): Cursor {
return parseLiteralImpl(input, Locale.getDefault())
}
@Throws(CoercingParseLiteralException::class)
override fun parseLiteral(
input: Value<*>,
variables: CoercedVariables,
graphQLContext: GraphQLContext,
locale: Locale
): Cursor {
return parseLiteralImpl(input, locale)
}
@Deprecated("")
override fun valueToLiteral(input: Any): Value<*> {
return valueToLiteralImpl(input)
}
override fun valueToLiteral(
input: Any,
graphQLContext: GraphQLContext,
locale: Locale
): Value<*> {
return valueToLiteralImpl(input)
}
}

View File

@@ -0,0 +1,105 @@
package suwayomi.tachidesk.graphql.server.primitives
import graphql.GraphQLContext
import graphql.execution.CoercedVariables
import graphql.language.StringValue
import graphql.language.Value
import graphql.scalar.CoercingUtil
import graphql.schema.Coercing
import graphql.schema.CoercingParseLiteralException
import graphql.schema.CoercingParseValueException
import graphql.schema.CoercingSerializeException
import graphql.schema.GraphQLScalarType
import java.util.Locale
val GraphQLLongAsString: GraphQLScalarType = GraphQLScalarType.newScalar()
.name("LongString").description("A 64-bit signed integer as a String").coercing(GraphqlLongAsStringCoercing()).build()
private class GraphqlLongAsStringCoercing : Coercing<Long, String> {
private fun toStringImpl(input: Any): String {
return input.toString()
}
private fun parseValueImpl(input: Any, locale: Locale): Long {
if (input !is String) {
throw CoercingParseValueException(
CoercingUtil.i18nMsg(
locale,
"String.unexpectedRawValueType",
CoercingUtil.typeName(input)
)
)
}
return input.toLong()
}
private fun parseLiteralImpl(input: Any, locale: Locale): Long {
if (input !is StringValue) {
throw CoercingParseLiteralException(
CoercingUtil.i18nMsg(
locale,
"Scalar.unexpectedAstType",
"StringValue",
CoercingUtil.typeName(input)
)
)
}
return input.value.toLong()
}
private fun valueToLiteralImpl(input: Any): StringValue {
return StringValue.newStringValue(input.toString()).build()
}
@Deprecated("")
override fun serialize(dataFetcherResult: Any): String {
return toStringImpl(dataFetcherResult)
}
@Throws(CoercingSerializeException::class)
override fun serialize(
dataFetcherResult: Any,
graphQLContext: GraphQLContext,
locale: Locale
): String {
return toStringImpl(dataFetcherResult)
}
@Deprecated("")
override fun parseValue(input: Any): Long {
return parseValueImpl(input, Locale.getDefault())
}
@Throws(CoercingParseValueException::class)
override fun parseValue(input: Any, graphQLContext: GraphQLContext, locale: Locale): Long {
return parseValueImpl(input, locale)
}
@Deprecated("")
override fun parseLiteral(input: Any): Long {
return parseLiteralImpl(input, Locale.getDefault())
}
@Throws(CoercingParseLiteralException::class)
override fun parseLiteral(
input: Value<*>,
variables: CoercedVariables,
graphQLContext: GraphQLContext,
locale: Locale
): Long {
return parseLiteralImpl(input, locale)
}
@Deprecated("")
override fun valueToLiteral(input: Any): Value<*> {
return valueToLiteralImpl(input)
}
override fun valueToLiteral(
input: Any,
graphQLContext: GraphQLContext,
locale: Locale
): Value<*> {
return valueToLiteralImpl(input)
}
}

View File

@@ -0,0 +1,38 @@
package suwayomi.tachidesk.graphql.server.primitives
import com.expediagroup.graphql.generator.annotations.GraphQLDescription
interface Node
abstract class NodeList {
@GraphQLDescription("A list of [T] objects.")
abstract val nodes: List<Node>
@GraphQLDescription("A list of edges which contains the [T] and cursor to aid in pagination.")
abstract val edges: List<Edge>
@GraphQLDescription("Information to aid in pagination.")
abstract val pageInfo: PageInfo
@GraphQLDescription("The count of all nodes you could get from the connection.")
abstract val totalCount: Int
}
data class PageInfo(
@GraphQLDescription("When paginating forwards, are there more items?")
val hasNextPage: Boolean,
@GraphQLDescription("When paginating backwards, are there more items?")
val hasPreviousPage: Boolean,
@GraphQLDescription("When paginating backwards, the cursor to continue.")
val startCursor: Cursor?,
@GraphQLDescription("When paginating forwards, the cursor to continue.")
val endCursor: Cursor?
)
abstract class Edge {
@GraphQLDescription("A cursor for use in pagination.")
abstract val cursor: Cursor
@GraphQLDescription("The [T] at the end of the edge.")
abstract val node: Node
}

View File

@@ -0,0 +1,125 @@
package suwayomi.tachidesk.graphql.server.primitives
import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.sql.Column
import org.jetbrains.exposed.sql.Op
import org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.SqlExpressionBuilder.greater
import org.jetbrains.exposed.sql.SqlExpressionBuilder.less
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.or
interface OrderBy<T> {
val column: Column<out Comparable<*>>
fun asCursor(type: T): Cursor
fun greater(cursor: Cursor): Op<Boolean>
fun less(cursor: Cursor): Op<Boolean>
}
fun SortOrder?.maybeSwap(value: Any?): SortOrder {
return if (value != null) {
when (this) {
SortOrder.ASC -> SortOrder.DESC
SortOrder.DESC -> SortOrder.ASC
SortOrder.ASC_NULLS_FIRST -> SortOrder.DESC_NULLS_LAST
SortOrder.DESC_NULLS_FIRST -> SortOrder.ASC_NULLS_LAST
SortOrder.ASC_NULLS_LAST -> SortOrder.DESC_NULLS_FIRST
SortOrder.DESC_NULLS_LAST -> SortOrder.ASC_NULLS_FIRST
null -> SortOrder.DESC
}
} else {
this ?: SortOrder.ASC
}
}
@JvmName("greaterNotUniqueIntKey")
fun <T : Comparable<T>> greaterNotUnique(
column: Column<T>,
idColumn: Column<EntityID<Int>>,
cursor: Cursor,
toValue: (String) -> T
): Op<Boolean> {
return greaterNotUniqueImpl(column, idColumn, cursor, String::toInt, toValue)
}
@JvmName("greaterNotUniqueLongKey")
fun <T : Comparable<T>> greaterNotUnique(
column: Column<T>,
idColumn: Column<EntityID<Long>>,
cursor: Cursor,
toValue: (String) -> T
): Op<Boolean> {
return greaterNotUniqueImpl(column, idColumn, cursor, String::toLong, toValue)
}
private fun <K : Comparable<K>, V : Comparable<V>> greaterNotUniqueImpl(
column: Column<V>,
idColumn: Column<EntityID<K>>,
cursor: Cursor,
toKey: (String) -> K,
toValue: (String) -> V
): Op<Boolean> {
val id = toKey(cursor.value.substringBefore('-'))
val value = toValue(cursor.value.substringAfter('-'))
return (column greater value) or ((column eq value) and (idColumn greater id))
}
@JvmName("greaterNotUniqueStringKey")
fun <T : Comparable<T>> greaterNotUnique(
column: Column<T>,
idColumn: Column<String>,
cursor: Cursor,
toValue: (String) -> T
): Op<Boolean> {
val id = cursor.value.substringBefore("\\-")
val value = toValue(cursor.value.substringAfter("\\-"))
return (column greater value) or ((column eq value) and (idColumn greater id))
}
@JvmName("lessNotUniqueIntKey")
fun <T : Comparable<T>> lessNotUnique(
column: Column<T>,
idColumn: Column<EntityID<Int>>,
cursor: Cursor,
toValue: (String) -> T
): Op<Boolean> {
return lessNotUniqueImpl(column, idColumn, cursor, String::toInt, toValue)
}
@JvmName("lessNotUniqueLongKey")
fun <T : Comparable<T>> lessNotUnique(
column: Column<T>,
idColumn: Column<EntityID<Long>>,
cursor: Cursor,
toValue: (String) -> T
): Op<Boolean> {
return lessNotUniqueImpl(column, idColumn, cursor, String::toLong, toValue)
}
private fun <K : Comparable<K>, V : Comparable<V>> lessNotUniqueImpl(
column: Column<V>,
idColumn: Column<EntityID<K>>,
cursor: Cursor,
toKey: (String) -> K,
toValue: (String) -> V
): Op<Boolean> {
val id = toKey(cursor.value.substringBefore('-'))
val value = toValue(cursor.value.substringAfter('-'))
return (column less value) or ((column eq value) and (idColumn less id))
}
@JvmName("lessNotUniqueStringKey")
fun <T : Comparable<T>> lessNotUnique(
column: Column<T>,
idColumn: Column<String>,
cursor: Cursor,
toValue: (String) -> T
): Op<Boolean> {
val id = cursor.value.substringBefore("\\-")
val value = toValue(cursor.value.substringAfter("\\-"))
return (column less value) or ((column eq value) and (idColumn less id))
}

View File

@@ -0,0 +1,5 @@
package suwayomi.tachidesk.graphql.server.primitives
import org.jetbrains.exposed.sql.ResultRow
data class QueryResults<T>(val total: Long, val firstKey: T, val lastKey: T, val results: List<ResultRow>)

View File

@@ -0,0 +1,207 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
package suwayomi.tachidesk.graphql.server.subscriptions
import com.expediagroup.graphql.server.types.GraphQLRequest
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.convertValue
import com.fasterxml.jackson.module.kotlin.readValue
import io.javalin.websocket.WsContext
import io.javalin.websocket.WsMessageContext
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.job
import kotlinx.coroutines.runBlocking
import mu.KotlinLogging
import suwayomi.tachidesk.graphql.server.TachideskGraphQLContextFactory
import suwayomi.tachidesk.graphql.server.subscriptions.SubscriptionOperationMessage.ClientMessages.GQL_CONNECTION_INIT
import suwayomi.tachidesk.graphql.server.subscriptions.SubscriptionOperationMessage.ClientMessages.GQL_CONNECTION_TERMINATE
import suwayomi.tachidesk.graphql.server.subscriptions.SubscriptionOperationMessage.ClientMessages.GQL_START
import suwayomi.tachidesk.graphql.server.subscriptions.SubscriptionOperationMessage.ClientMessages.GQL_STOP
import suwayomi.tachidesk.graphql.server.subscriptions.SubscriptionOperationMessage.ServerMessages.GQL_CONNECTION_ACK
import suwayomi.tachidesk.graphql.server.subscriptions.SubscriptionOperationMessage.ServerMessages.GQL_CONNECTION_ERROR
import suwayomi.tachidesk.graphql.server.subscriptions.SubscriptionOperationMessage.ServerMessages.GQL_CONNECTION_KEEP_ALIVE
import suwayomi.tachidesk.graphql.server.subscriptions.SubscriptionOperationMessage.ServerMessages.GQL_DATA
import suwayomi.tachidesk.graphql.server.subscriptions.SubscriptionOperationMessage.ServerMessages.GQL_ERROR
import suwayomi.tachidesk.graphql.server.toGraphQLContext
/**
* Implementation of the `graphql-ws` protocol defined by Apollo
* https://github.com/apollographql/subscriptions-transport-ws/blob/master/PROTOCOL.md
* ported for Javalin
*/
class ApolloSubscriptionProtocolHandler(
private val contextFactory: TachideskGraphQLContextFactory,
private val subscriptionHandler: GraphQLSubscriptionHandler,
private val objectMapper: ObjectMapper
) {
private val sessionState = ApolloSubscriptionSessionState()
private val logger = KotlinLogging.logger {}
private val keepAliveMessage = SubscriptionOperationMessage(type = GQL_CONNECTION_KEEP_ALIVE.type)
private val basicConnectionErrorMessage = SubscriptionOperationMessage(type = GQL_CONNECTION_ERROR.type)
private val acknowledgeMessage = SubscriptionOperationMessage(GQL_CONNECTION_ACK.type)
fun handleMessage(context: WsMessageContext): Flow<SubscriptionOperationMessage> {
val operationMessage = convertToMessageOrNull(context.message()) ?: return flowOf(basicConnectionErrorMessage)
logger.debug { "GraphQL subscription client message, sessionId=${context.sessionId} operationMessage=$operationMessage" }
return try {
when (operationMessage.type) {
GQL_CONNECTION_INIT.type -> onInit(operationMessage, context)
GQL_START.type -> startSubscription(operationMessage, context)
GQL_STOP.type -> onStop(operationMessage, context)
GQL_CONNECTION_TERMINATE.type -> onDisconnect(context)
else -> onUnknownOperation(operationMessage, context)
}
} catch (exception: Exception) {
onException(exception)
}
}
fun handleDisconnect(context: WsContext) {
onDisconnect(context)
}
private fun convertToMessageOrNull(payload: String): SubscriptionOperationMessage? {
return try {
objectMapper.readValue(payload)
} catch (exception: Exception) {
logger.error("Error parsing the subscription message", exception)
null
}
}
/**
* If the keep alive configuration is set, send a message back to client at every interval until the session is terminated.
* Otherwise just return empty flux to append to the acknowledge message.
*/
@OptIn(FlowPreview::class)
private fun getKeepAliveFlow(context: WsContext): Flow<SubscriptionOperationMessage> {
val keepAliveInterval: Long? = 2000
if (keepAliveInterval != null) {
return flowOf(keepAliveMessage).sample(keepAliveInterval)
.onStart {
sessionState.saveKeepAliveSubscription(context, currentCoroutineContext().job)
}
}
return emptyFlow()
}
@Suppress("Detekt.TooGenericExceptionCaught")
private fun startSubscription(
operationMessage: SubscriptionOperationMessage,
context: WsContext
): Flow<SubscriptionOperationMessage> {
val graphQLContext = sessionState.getGraphQLContext(context)
if (operationMessage.id == null) {
logger.error("GraphQL subscription operation id is required")
return flowOf(basicConnectionErrorMessage)
}
if (sessionState.doesOperationExist(context, operationMessage)) {
logger.info("Already subscribed to operation ${operationMessage.id} for session ${context.sessionId}")
return emptyFlow()
}
val payload = operationMessage.payload
if (payload == null) {
logger.error("GraphQL subscription payload was null instead of a GraphQLRequest object")
sessionState.stopOperation(context, operationMessage)
return flowOf(SubscriptionOperationMessage(type = GQL_CONNECTION_ERROR.type, id = operationMessage.id))
}
try {
val request = objectMapper.convertValue<GraphQLRequest>(payload)
return subscriptionHandler.executeSubscription(request, graphQLContext)
.map {
if (it.errors?.isNotEmpty() == true) {
SubscriptionOperationMessage(type = GQL_ERROR.type, id = operationMessage.id, payload = it)
} else {
SubscriptionOperationMessage(type = GQL_DATA.type, id = operationMessage.id, payload = it)
}
}
.onCompletion { if (it == null) emitAll(onComplete(operationMessage, context)) }
.onStart { sessionState.saveOperation(context, operationMessage, currentCoroutineContext().job) }
} catch (exception: Exception) {
logger.error("Error running graphql subscription", exception)
// Do not terminate the session, just stop the operation messages
sessionState.stopOperation(context, operationMessage)
return flowOf(SubscriptionOperationMessage(type = GQL_CONNECTION_ERROR.type, id = operationMessage.id))
}
}
private fun onInit(operationMessage: SubscriptionOperationMessage, context: WsContext): Flow<SubscriptionOperationMessage> {
saveContext(operationMessage, context)
val acknowledgeMessage = flowOf(acknowledgeMessage)
val keepAliveFlux = getKeepAliveFlow(context)
return acknowledgeMessage.onCompletion { if (it == null) emitAll(keepAliveFlux) }
.catch { emit(getConnectionErrorMessage(operationMessage)) }
}
/**
* Generate the context and save it for all future messages.
*/
private fun saveContext(operationMessage: SubscriptionOperationMessage, context: WsContext) {
runBlocking {
val graphQLContext = contextFactory.generateContextMap(context).toGraphQLContext()
sessionState.saveContext(context, graphQLContext)
}
}
/**
* Called with the publisher has completed on its own.
*/
private fun onComplete(
operationMessage: SubscriptionOperationMessage,
context: WsContext
): Flow<SubscriptionOperationMessage> {
return sessionState.completeOperation(context, operationMessage)
}
/**
* Called with the client has called stop manually, or on error, and we need to cancel the publisher
*/
private fun onStop(
operationMessage: SubscriptionOperationMessage,
context: WsContext
): Flow<SubscriptionOperationMessage> {
return sessionState.stopOperation(context, operationMessage)
}
private fun onDisconnect(context: WsContext): Flow<SubscriptionOperationMessage> {
sessionState.terminateSession(context)
return emptyFlow()
}
private fun onUnknownOperation(operationMessage: SubscriptionOperationMessage, context: WsContext): Flow<SubscriptionOperationMessage> {
logger.error("Unknown subscription operation $operationMessage")
sessionState.stopOperation(context, operationMessage)
return flowOf(getConnectionErrorMessage(operationMessage))
}
private fun onException(exception: Exception): Flow<SubscriptionOperationMessage> {
logger.error("Error parsing the subscription message", exception)
return flowOf(basicConnectionErrorMessage)
}
private fun getConnectionErrorMessage(operationMessage: SubscriptionOperationMessage): SubscriptionOperationMessage {
return SubscriptionOperationMessage(type = GQL_CONNECTION_ERROR.type, id = operationMessage.id)
}
}

View File

@@ -0,0 +1,128 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
package suwayomi.tachidesk.graphql.server.subscriptions
import graphql.GraphQLContext
import io.javalin.websocket.WsContext
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.onCompletion
import suwayomi.tachidesk.graphql.server.subscriptions.SubscriptionOperationMessage.ServerMessages.GQL_COMPLETE
import suwayomi.tachidesk.graphql.server.toGraphQLContext
import java.util.concurrent.ConcurrentHashMap
internal class ApolloSubscriptionSessionState {
// Sessions are saved by web socket session id
internal val activeKeepAliveSessions = ConcurrentHashMap<String, Job>()
// Operations are saved by web socket session id, then operation id
internal val activeOperations = ConcurrentHashMap<String, ConcurrentHashMap<String, Job>>()
// The graphQL context is saved by web socket session id
private val cachedGraphQLContext = ConcurrentHashMap<String, GraphQLContext>()
/**
* Save the context created from the factory and possibly updated in the onConnect hook.
* This allows us to include some initial state to be used when handling all the messages.
* This will be removed in [terminateSession].
*/
fun saveContext(context: WsContext, graphQLContext: GraphQLContext) {
cachedGraphQLContext[context.sessionId] = graphQLContext
}
/**
* Return the graphQL context for this session.
*/
fun getGraphQLContext(context: WsContext): GraphQLContext = cachedGraphQLContext[context.sessionId] ?: emptyMap<Any, Any>().toGraphQLContext()
/**
* Save the session that is sending keep alive messages.
* This will override values without cancelling the subscription, so it is the responsibility of the consumer to cancel.
* These messages will be stopped on [terminateSession].
*/
fun saveKeepAliveSubscription(context: WsContext, subscription: Job) {
activeKeepAliveSessions[context.sessionId] = subscription
}
/**
* Save the operation that is sending data to the client.
* This will override values without cancelling the subscription so it is the responsibility of the consumer to cancel.
* These messages will be stopped on [stopOperation].
*/
fun saveOperation(context: WsContext, operationMessage: SubscriptionOperationMessage, subscription: Job) {
val id = operationMessage.id
if (id != null) {
val operationsForSession: ConcurrentHashMap<String, Job> = activeOperations.getOrPut(context.sessionId) { ConcurrentHashMap() }
operationsForSession[id] = subscription
}
}
/**
* Send the [GQL_COMPLETE] message.
* This can happen when the publisher finishes or if the client manually sends the stop message.
*/
fun completeOperation(context: WsContext, operationMessage: SubscriptionOperationMessage): Flow<SubscriptionOperationMessage> {
return getCompleteMessage(operationMessage)
.onCompletion { removeActiveOperation(context, operationMessage.id, cancelSubscription = false) }
}
/**
* Stop the subscription sending data and send the [GQL_COMPLETE] message.
* Does NOT terminate the session.
*/
fun stopOperation(context: WsContext, operationMessage: SubscriptionOperationMessage): Flow<SubscriptionOperationMessage> {
return getCompleteMessage(operationMessage)
.onCompletion { removeActiveOperation(context, operationMessage.id, cancelSubscription = true) }
}
private fun getCompleteMessage(operationMessage: SubscriptionOperationMessage): Flow<SubscriptionOperationMessage> {
val id = operationMessage.id
if (id != null) {
return flowOf(SubscriptionOperationMessage(type = GQL_COMPLETE.type, id = id))
}
return emptyFlow()
}
/**
* Remove active running subscription from the cache and cancel if needed
*/
private fun removeActiveOperation(context: WsContext, id: String?, cancelSubscription: Boolean) {
val operationsForSession = activeOperations[context.sessionId]
val subscription = operationsForSession?.get(id)
if (subscription != null) {
if (cancelSubscription) {
subscription.cancel()
}
operationsForSession.remove(id)
if (operationsForSession.isEmpty()) {
activeOperations.remove(context.sessionId)
}
}
}
/**
* Terminate the session, cancelling the keep alive messages and all operations active for this session.
*/
fun terminateSession(context: WsContext) {
activeOperations[context.sessionId]?.forEach { (_, subscription) -> subscription.cancel() }
activeOperations.remove(context.sessionId)
cachedGraphQLContext.remove(context.sessionId)
activeKeepAliveSessions[context.sessionId]?.cancel()
activeKeepAliveSessions.remove(context.sessionId)
context.closeSession()
}
/**
* Looks up the operation for the client, to check if it already exists
*/
fun doesOperationExist(context: WsContext, operationMessage: SubscriptionOperationMessage): Boolean =
activeOperations[context.sessionId]?.containsKey(operationMessage.id) ?: false
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
package suwayomi.tachidesk.graphql.server.subscriptions
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
class FlowSubscriptionSource<T : Any> {
private val mutableSharedFlow = MutableSharedFlow<T>()
val emitter = mutableSharedFlow.asSharedFlow()
fun publish(value: T) {
mutableSharedFlow.tryEmit(value)
}
}

View File

@@ -0,0 +1,43 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
package suwayomi.tachidesk.graphql.server.subscriptions
import com.expediagroup.graphql.dataloader.KotlinDataLoaderRegistryFactory
import com.expediagroup.graphql.server.extensions.toExecutionInput
import com.expediagroup.graphql.server.extensions.toGraphQLError
import com.expediagroup.graphql.server.extensions.toGraphQLKotlinType
import com.expediagroup.graphql.server.extensions.toGraphQLResponse
import com.expediagroup.graphql.server.types.GraphQLRequest
import com.expediagroup.graphql.server.types.GraphQLResponse
import graphql.ExecutionResult
import graphql.GraphQL
import graphql.GraphQLContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
open class GraphQLSubscriptionHandler(
private val graphQL: GraphQL,
private val dataLoaderRegistryFactory: KotlinDataLoaderRegistryFactory? = null
) {
open fun executeSubscription(
graphQLRequest: GraphQLRequest,
graphQLContext: GraphQLContext = GraphQLContext.of(emptyMap<Any, Any>())
): Flow<GraphQLResponse<*>> {
val dataLoaderRegistry = dataLoaderRegistryFactory?.generate()
val input = graphQLRequest.toExecutionInput(dataLoaderRegistry, graphQLContext)
val res = graphQL.execute(input)
val data = res.getData<Flow<ExecutionResult>>()
val mapped = data.map { result -> result.toGraphQLResponse() }
return mapped.catch { throwable ->
val error = throwable.toGraphQLError()
emit(GraphQLResponse<Any?>(errors = listOf(error.toGraphQLKotlinType())))
}
}
}

View File

@@ -0,0 +1,39 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
package suwayomi.tachidesk.graphql.server.subscriptions
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
/**
* The `graphql-ws` protocol from Apollo Client has some special text messages to signal events.
* Along with the HTTP WebSocket event handling we need to have some extra logic
*
* https://github.com/apollographql/subscriptions-transport-ws/blob/master/PROTOCOL.md
*/
@JsonIgnoreProperties(ignoreUnknown = true)
data class SubscriptionOperationMessage(
val type: String,
val id: String? = null,
val payload: Any? = null
) {
enum class ClientMessages(val type: String) {
GQL_CONNECTION_INIT("connection_init"),
GQL_START("start"),
GQL_STOP("stop"),
GQL_CONNECTION_TERMINATE("connection_terminate")
}
enum class ServerMessages(val type: String) {
GQL_CONNECTION_ACK("connection_ack"),
GQL_CONNECTION_ERROR("connection_error"),
GQL_DATA("data"),
GQL_ERROR("error"),
GQL_COMPLETE("complete"),
GQL_CONNECTION_KEEP_ALIVE("ka")
}
}

View File

@@ -0,0 +1,25 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
package suwayomi.tachidesk.graphql.subscriptions
import graphql.schema.DataFetchingEnvironment
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import suwayomi.tachidesk.graphql.server.subscriptions.FlowSubscriptionSource
import suwayomi.tachidesk.graphql.types.DownloadType
import suwayomi.tachidesk.manga.impl.download.model.DownloadChapter
val downloadSubscriptionSource = FlowSubscriptionSource<DownloadChapter>()
class DownloadSubscription {
fun downloadChanged(dataFetchingEnvironment: DataFetchingEnvironment): Flow<DownloadType> {
return downloadSubscriptionSource.emitter.map { downloadChapter ->
DownloadType(downloadChapter)
}
}
}

View File

@@ -0,0 +1,83 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
package suwayomi.tachidesk.graphql.types
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
import graphql.schema.DataFetchingEnvironment
import org.jetbrains.exposed.sql.ResultRow
import suwayomi.tachidesk.graphql.server.primitives.Cursor
import suwayomi.tachidesk.graphql.server.primitives.Edge
import suwayomi.tachidesk.graphql.server.primitives.Node
import suwayomi.tachidesk.graphql.server.primitives.NodeList
import suwayomi.tachidesk.graphql.server.primitives.PageInfo
import suwayomi.tachidesk.manga.model.table.CategoryTable
import java.util.concurrent.CompletableFuture
class CategoryType(
val id: Int,
val order: Int,
val name: String,
val default: Boolean
) : Node {
constructor(row: ResultRow) : this(
row[CategoryTable.id].value,
row[CategoryTable.order],
row[CategoryTable.name],
row[CategoryTable.isDefault]
)
fun manga(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<MangaNodeList> {
return dataFetchingEnvironment.getValueFromDataLoader<Int, MangaNodeList>("MangaForCategoryDataLoader", id)
}
fun meta(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<MetaNodeList> {
return dataFetchingEnvironment.getValueFromDataLoader<Int, MetaNodeList>("CategoryMetaDataLoader", id)
}
}
data class CategoryNodeList(
override val nodes: List<CategoryType>,
override val edges: List<CategoryEdge>,
override val pageInfo: PageInfo,
override val totalCount: Int
) : NodeList() {
data class CategoryEdge(
override val cursor: Cursor,
override val node: CategoryType
) : Edge()
companion object {
fun List<CategoryType>.toNodeList(): CategoryNodeList {
return CategoryNodeList(
nodes = this,
edges = getEdges(),
pageInfo = PageInfo(
hasNextPage = false,
hasPreviousPage = false,
startCursor = Cursor(0.toString()),
endCursor = Cursor(lastIndex.toString())
),
totalCount = size
)
}
private fun List<CategoryType>.getEdges(): List<CategoryEdge> {
if (isEmpty()) return emptyList()
return listOf(
CategoryEdge(
cursor = Cursor("0"),
node = first()
),
CategoryEdge(
cursor = Cursor(lastIndex.toString()),
node = last()
)
)
}
}
}

View File

@@ -0,0 +1,129 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
package suwayomi.tachidesk.graphql.types
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
import graphql.schema.DataFetchingEnvironment
import org.jetbrains.exposed.sql.ResultRow
import suwayomi.tachidesk.graphql.server.primitives.Cursor
import suwayomi.tachidesk.graphql.server.primitives.Edge
import suwayomi.tachidesk.graphql.server.primitives.Node
import suwayomi.tachidesk.graphql.server.primitives.NodeList
import suwayomi.tachidesk.graphql.server.primitives.PageInfo
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
import suwayomi.tachidesk.manga.model.table.ChapterTable
import java.util.concurrent.CompletableFuture
class ChapterType(
val id: Int,
val url: String,
val name: String,
val uploadDate: Long,
val chapterNumber: Float,
val scanlator: String?,
val mangaId: Int,
val isRead: Boolean,
val isBookmarked: Boolean,
val lastPageRead: Int,
val lastReadAt: Long,
val sourceOrder: Int,
val realUrl: String?,
val fetchedAt: Long,
val isDownloaded: Boolean,
val pageCount: Int
// val chapterCount: Int?,
) : Node {
constructor(row: ResultRow) : this(
row[ChapterTable.id].value,
row[ChapterTable.url],
row[ChapterTable.name],
row[ChapterTable.date_upload],
row[ChapterTable.chapter_number],
row[ChapterTable.scanlator],
row[ChapterTable.manga].value,
row[ChapterTable.isRead],
row[ChapterTable.isBookmarked],
row[ChapterTable.lastPageRead],
row[ChapterTable.lastReadAt],
row[ChapterTable.sourceOrder],
row[ChapterTable.realUrl],
row[ChapterTable.fetchedAt],
row[ChapterTable.isDownloaded],
row[ChapterTable.pageCount]
// transaction { ChapterTable.select { manga eq chapterEntry[manga].value }.count().toInt() },
)
constructor(dataClass: ChapterDataClass) : this(
dataClass.id,
dataClass.url,
dataClass.name,
dataClass.uploadDate,
dataClass.chapterNumber,
dataClass.scanlator,
dataClass.mangaId,
dataClass.read,
dataClass.bookmarked,
dataClass.lastPageRead,
dataClass.lastReadAt,
dataClass.index,
dataClass.realUrl,
dataClass.fetchedAt,
dataClass.downloaded,
dataClass.pageCount
)
fun manga(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<MangaType> {
return dataFetchingEnvironment.getValueFromDataLoader<Int, MangaType>("MangaDataLoader", mangaId)
}
fun meta(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<MetaNodeList> {
return dataFetchingEnvironment.getValueFromDataLoader<Int, MetaNodeList>("ChapterMetaDataLoader", id)
}
}
data class ChapterNodeList(
override val nodes: List<ChapterType>,
override val edges: List<ChapterEdge>,
override val pageInfo: PageInfo,
override val totalCount: Int
) : NodeList() {
data class ChapterEdge(
override val cursor: Cursor,
override val node: ChapterType
) : Edge()
companion object {
fun List<ChapterType>.toNodeList(): ChapterNodeList {
return ChapterNodeList(
nodes = this,
edges = getEdges(),
pageInfo = PageInfo(
hasNextPage = false,
hasPreviousPage = false,
startCursor = Cursor(0.toString()),
endCursor = Cursor(lastIndex.toString())
),
totalCount = size
)
}
private fun List<ChapterType>.getEdges(): List<ChapterEdge> {
if (isEmpty()) return emptyList()
return listOf(
ChapterEdge(
cursor = Cursor("0"),
node = first()
),
ChapterEdge(
cursor = Cursor(lastIndex.toString()),
node = last()
)
)
}
}
}

View File

@@ -0,0 +1,93 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
package suwayomi.tachidesk.graphql.types
import com.expediagroup.graphql.generator.annotations.GraphQLIgnore
import suwayomi.tachidesk.graphql.server.primitives.Cursor
import suwayomi.tachidesk.graphql.server.primitives.Edge
import suwayomi.tachidesk.graphql.server.primitives.Node
import suwayomi.tachidesk.graphql.server.primitives.NodeList
import suwayomi.tachidesk.graphql.server.primitives.PageInfo
import suwayomi.tachidesk.manga.impl.download.model.DownloadChapter
import suwayomi.tachidesk.manga.impl.download.model.DownloadState
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
class DownloadType(
val chapterId: Int,
val chapterIndex: Int,
val mangaId: Int,
var state: DownloadState = DownloadState.Queued,
var progress: Float = 0f,
var tries: Int = 0,
@GraphQLIgnore
var mangaDataClass: MangaDataClass,
@GraphQLIgnore
var chapterDataClass: ChapterDataClass
) : Node {
constructor(downloadChapter: DownloadChapter) : this(
downloadChapter.chapter.id,
downloadChapter.chapterIndex,
downloadChapter.mangaId,
downloadChapter.state,
downloadChapter.progress,
downloadChapter.tries,
downloadChapter.manga,
downloadChapter.chapter
)
fun manga(): MangaType {
return MangaType(mangaDataClass)
}
fun chapter(): ChapterType {
return ChapterType(chapterDataClass)
}
}
data class DownloadNodeList(
override val nodes: List<DownloadType>,
override val edges: List<DownloadEdge>,
override val pageInfo: PageInfo,
override val totalCount: Int
) : NodeList() {
data class DownloadEdge(
override val cursor: Cursor,
override val node: DownloadType
) : Edge()
companion object {
fun List<DownloadType>.toNodeList(): DownloadNodeList {
return DownloadNodeList(
nodes = this,
edges = getEdges(),
pageInfo = PageInfo(
hasNextPage = false,
hasPreviousPage = false,
startCursor = Cursor(0.toString()),
endCursor = Cursor(lastIndex.toString())
),
totalCount = size
)
}
private fun List<DownloadType>.getEdges(): List<DownloadEdge> {
if (isEmpty()) return emptyList()
return listOf(
DownloadEdge(
cursor = Cursor("0"),
node = first()
),
DownloadEdge(
cursor = Cursor(lastIndex.toString()),
node = last()
)
)
}
}
}

View File

@@ -0,0 +1,95 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
package suwayomi.tachidesk.graphql.types
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
import graphql.schema.DataFetchingEnvironment
import org.jetbrains.exposed.sql.ResultRow
import suwayomi.tachidesk.graphql.server.primitives.Cursor
import suwayomi.tachidesk.graphql.server.primitives.Edge
import suwayomi.tachidesk.graphql.server.primitives.Node
import suwayomi.tachidesk.graphql.server.primitives.NodeList
import suwayomi.tachidesk.graphql.server.primitives.PageInfo
import suwayomi.tachidesk.manga.model.table.ExtensionTable
import java.util.concurrent.CompletableFuture
class ExtensionType(
val apkName: String,
val iconUrl: String,
val name: String,
val pkgName: String,
val versionName: String,
val versionCode: Int,
val lang: String,
val isNsfw: Boolean,
val isInstalled: Boolean,
val hasUpdate: Boolean,
val isObsolete: Boolean
) : Node {
constructor(row: ResultRow) : this(
apkName = row[ExtensionTable.apkName],
iconUrl = row[ExtensionTable.iconUrl],
name = row[ExtensionTable.name],
pkgName = row[ExtensionTable.pkgName],
versionName = row[ExtensionTable.versionName],
versionCode = row[ExtensionTable.versionCode],
lang = row[ExtensionTable.lang],
isNsfw = row[ExtensionTable.isNsfw],
isInstalled = row[ExtensionTable.isInstalled],
hasUpdate = row[ExtensionTable.hasUpdate],
isObsolete = row[ExtensionTable.isObsolete]
)
fun source(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<SourceNodeList> {
return dataFetchingEnvironment.getValueFromDataLoader<String, SourceNodeList>("SourcesForExtensionDataLoader", pkgName)
}
}
data class ExtensionNodeList(
override val nodes: List<ExtensionType>,
override val edges: List<ExtensionEdge>,
override val pageInfo: PageInfo,
override val totalCount: Int
) : NodeList() {
data class ExtensionEdge(
override val cursor: Cursor,
override val node: ExtensionType
) : Edge()
companion object {
fun List<ExtensionType>.toNodeList(): ExtensionNodeList {
return ExtensionNodeList(
nodes = this,
edges = getEdges(),
pageInfo = PageInfo(
hasNextPage = false,
hasPreviousPage = false,
startCursor = Cursor(0.toString()),
endCursor = Cursor(lastIndex.toString())
),
totalCount = size
)
}
private fun List<ExtensionType>.getEdges(): List<ExtensionEdge> {
if (isEmpty()) return emptyList()
return listOf(
ExtensionEdge(
cursor = Cursor("0"),
node = first()
),
ExtensionEdge(
cursor = Cursor(lastIndex.toString()),
node = last()
)
)
}
}
}

View File

@@ -0,0 +1,149 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
package suwayomi.tachidesk.graphql.types
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
import graphql.schema.DataFetchingEnvironment
import org.jetbrains.exposed.sql.ResultRow
import suwayomi.tachidesk.graphql.server.primitives.Cursor
import suwayomi.tachidesk.graphql.server.primitives.Edge
import suwayomi.tachidesk.graphql.server.primitives.Node
import suwayomi.tachidesk.graphql.server.primitives.NodeList
import suwayomi.tachidesk.graphql.server.primitives.PageInfo
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
import suwayomi.tachidesk.manga.model.dataclass.toGenreList
import suwayomi.tachidesk.manga.model.table.MangaStatus
import suwayomi.tachidesk.manga.model.table.MangaTable
import java.time.Instant
import java.util.concurrent.CompletableFuture
class MangaType(
val id: Int,
val sourceId: Long,
val url: String,
val title: String,
val thumbnailUrl: String?,
val initialized: Boolean,
val artist: String?,
val author: String?,
val description: String?,
val genre: List<String>,
val status: MangaStatus,
val inLibrary: Boolean,
val inLibraryAt: Long,
val realUrl: String?,
var lastFetchedAt: Long?, // todo
var chaptersLastFetchedAt: Long? // todo
) : Node {
constructor(row: ResultRow) : this(
row[MangaTable.id].value,
row[MangaTable.sourceReference],
row[MangaTable.url],
row[MangaTable.title],
row[MangaTable.thumbnail_url],
row[MangaTable.initialized],
row[MangaTable.artist],
row[MangaTable.author],
row[MangaTable.description],
row[MangaTable.genre].toGenreList(),
MangaStatus.valueOf(row[MangaTable.status]),
row[MangaTable.inLibrary],
row[MangaTable.inLibraryAt],
row[MangaTable.realUrl],
row[MangaTable.lastFetchedAt],
row[MangaTable.chaptersLastFetchedAt]
)
constructor(dataClass: MangaDataClass) : this(
dataClass.id,
dataClass.sourceId.toLong(),
dataClass.url,
dataClass.title,
dataClass.thumbnailUrl,
dataClass.initialized,
dataClass.artist,
dataClass.author,
dataClass.description,
dataClass.genre,
MangaStatus.valueOf(dataClass.status),
dataClass.inLibrary,
dataClass.inLibraryAt,
dataClass.realUrl,
dataClass.lastFetchedAt,
dataClass.chaptersLastFetchedAt
)
fun chapters(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<List<ChapterType>> {
return dataFetchingEnvironment.getValueFromDataLoader<Int, List<ChapterType>>("ChaptersForMangaDataLoader", id)
}
fun age(): Long? {
if (lastFetchedAt == null) return null
return Instant.now().epochSecond.minus(lastFetchedAt!!)
}
fun chaptersAge(): Long? {
if (chaptersLastFetchedAt == null) return null
return Instant.now().epochSecond.minus(chaptersLastFetchedAt!!)
}
fun meta(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<MetaNodeList> {
return dataFetchingEnvironment.getValueFromDataLoader<Int, MetaNodeList>("MangaMetaDataLoader", id)
}
fun categories(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<CategoryNodeList> {
return dataFetchingEnvironment.getValueFromDataLoader<Int, CategoryNodeList>("CategoriesForMangaDataLoader", id)
}
fun source(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<SourceType?> {
return dataFetchingEnvironment.getValueFromDataLoader<Int, SourceType?>("SourceForMangaDataLoader", id)
}
}
data class MangaNodeList(
override val nodes: List<MangaType>,
override val edges: List<MangaEdge>,
override val pageInfo: PageInfo,
override val totalCount: Int
) : NodeList() {
data class MangaEdge(
override val cursor: Cursor,
override val node: MangaType
) : Edge()
companion object {
fun List<MangaType>.toNodeList(): MangaNodeList {
return MangaNodeList(
nodes = this,
edges = getEdges(),
pageInfo = PageInfo(
hasNextPage = false,
hasPreviousPage = false,
startCursor = Cursor(0.toString()),
endCursor = Cursor(lastIndex.toString())
),
totalCount = size
)
}
private fun List<MangaType>.getEdges(): List<MangaEdge> {
if (isEmpty()) return emptyList()
return listOf(
MangaEdge(
cursor = Cursor("0"),
node = first()
),
MangaEdge(
cursor = Cursor(lastIndex.toString()),
node = last()
)
)
}
}
}

View File

@@ -0,0 +1,78 @@
package suwayomi.tachidesk.graphql.types
import com.expediagroup.graphql.generator.annotations.GraphQLIgnore
import org.jetbrains.exposed.sql.ResultRow
import suwayomi.tachidesk.global.model.table.GlobalMetaTable
import suwayomi.tachidesk.graphql.server.primitives.Cursor
import suwayomi.tachidesk.graphql.server.primitives.Edge
import suwayomi.tachidesk.graphql.server.primitives.Node
import suwayomi.tachidesk.graphql.server.primitives.NodeList
import suwayomi.tachidesk.graphql.server.primitives.PageInfo
import suwayomi.tachidesk.manga.model.table.CategoryMetaTable
import suwayomi.tachidesk.manga.model.table.ChapterMetaTable
import suwayomi.tachidesk.manga.model.table.MangaMetaTable
open class MetaItem(
val key: String,
val value: String,
@GraphQLIgnore
val ref: Int?
) : Node
class ChapterMetaItem(
private val row: ResultRow
) : MetaItem(row[ChapterMetaTable.key], row[ChapterMetaTable.value], row[ChapterMetaTable.ref].value)
class MangaMetaItem(
private val row: ResultRow
) : MetaItem(row[MangaMetaTable.key], row[MangaMetaTable.value], row[MangaMetaTable.ref].value)
class CategoryMetaItem(
private val row: ResultRow
) : MetaItem(row[CategoryMetaTable.key], row[CategoryMetaTable.value], row[CategoryMetaTable.ref].value)
class GlobalMetaItem(
private val row: ResultRow
) : MetaItem(row[GlobalMetaTable.key], row[GlobalMetaTable.value], null)
data class MetaNodeList(
override val nodes: List<MetaItem>,
override val edges: List<MetaEdge>,
override val pageInfo: PageInfo,
override val totalCount: Int
) : NodeList() {
data class MetaEdge(
override val cursor: Cursor,
override val node: MetaItem
) : Edge()
companion object {
fun List<MetaItem>.toNodeList(): MetaNodeList {
return MetaNodeList(
nodes = this,
edges = getEdges(),
pageInfo = PageInfo(
hasNextPage = false,
hasPreviousPage = false,
startCursor = Cursor(0.toString()),
endCursor = Cursor(lastIndex.toString())
),
totalCount = size
)
}
private fun List<MetaItem>.getEdges(): List<MetaEdge> {
if (isEmpty()) return emptyList()
return listOf(
MetaEdge(
cursor = Cursor("0"),
node = first()
),
MetaEdge(
cursor = Cursor(lastIndex.toString()),
node = last()
)
)
}
}
}

View File

@@ -0,0 +1,124 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
package suwayomi.tachidesk.graphql.types
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.ConfigurableSource
import graphql.schema.DataFetchingEnvironment
import org.jetbrains.exposed.sql.ResultRow
import org.jetbrains.exposed.sql.select
import suwayomi.tachidesk.graphql.server.primitives.Cursor
import suwayomi.tachidesk.graphql.server.primitives.Edge
import suwayomi.tachidesk.graphql.server.primitives.Node
import suwayomi.tachidesk.graphql.server.primitives.NodeList
import suwayomi.tachidesk.graphql.server.primitives.PageInfo
import suwayomi.tachidesk.manga.impl.extension.Extension
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource
import suwayomi.tachidesk.manga.model.dataclass.SourceDataClass
import suwayomi.tachidesk.manga.model.table.ExtensionTable
import suwayomi.tachidesk.manga.model.table.SourceTable
import java.util.concurrent.CompletableFuture
class SourceType(
val id: Long,
val name: String,
val lang: String,
val iconUrl: String,
val supportsLatest: Boolean,
val isConfigurable: Boolean,
val isNsfw: Boolean,
val displayName: String
) : Node {
constructor(source: SourceDataClass) : this(
id = source.id.toLong(),
name = source.name,
lang = source.lang,
iconUrl = source.iconUrl,
supportsLatest = source.supportsLatest,
isConfigurable = source.isConfigurable,
isNsfw = source.isNsfw,
displayName = source.displayName
)
constructor(row: ResultRow, sourceExtension: ResultRow, catalogueSource: CatalogueSource) : this(
id = row[SourceTable.id].value,
name = row[SourceTable.name],
lang = row[SourceTable.lang],
iconUrl = Extension.getExtensionIconUrl(sourceExtension[ExtensionTable.apkName]),
supportsLatest = catalogueSource.supportsLatest,
isConfigurable = catalogueSource is ConfigurableSource,
isNsfw = row[SourceTable.isNsfw],
displayName = catalogueSource.toString()
)
fun manga(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<MangaNodeList> {
return dataFetchingEnvironment.getValueFromDataLoader<Long, MangaNodeList>("MangaForSourceDataLoader", id)
}
fun extension(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<ExtensionType> {
return dataFetchingEnvironment.getValueFromDataLoader<Long, ExtensionType>("ExtensionForSourceDataLoader", id)
}
}
fun SourceType(row: ResultRow): SourceType? {
val catalogueSource = GetCatalogueSource
.getCatalogueSourceOrNull(row[SourceTable.id].value)
?: return null
val sourceExtension = if (row.hasValue(ExtensionTable.id)) {
row
} else {
ExtensionTable
.select { ExtensionTable.id eq row[SourceTable.extension] }
.first()
}
return SourceType(row, sourceExtension, catalogueSource)
}
data class SourceNodeList(
override val nodes: List<SourceType>,
override val edges: List<SourceEdge>,
override val pageInfo: PageInfo,
override val totalCount: Int
) : NodeList() {
data class SourceEdge(
override val cursor: Cursor,
override val node: SourceType
) : Edge()
companion object {
fun List<SourceType>.toNodeList(): SourceNodeList {
return SourceNodeList(
nodes = this,
edges = getEdges(),
pageInfo = PageInfo(
hasNextPage = false,
hasPreviousPage = false,
startCursor = Cursor(0.toString()),
endCursor = Cursor(lastIndex.toString())
),
totalCount = size
)
}
private fun List<SourceType>.getEdges(): List<SourceEdge> {
if (isEmpty()) return emptyList()
return listOf(
SourceEdge(
cursor = Cursor("0"),
node = first()
),
SourceEdge(
cursor = Cursor(lastIndex.toString()),
node = last()
)
)
}
}
}

View File

@@ -13,7 +13,11 @@ import suwayomi.tachidesk.manga.impl.Chapter
import suwayomi.tachidesk.manga.impl.update.IUpdater
import suwayomi.tachidesk.manga.impl.update.UpdateStatus
import suwayomi.tachidesk.manga.impl.update.UpdaterSocket
import suwayomi.tachidesk.manga.model.dataclass.*
import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass
import suwayomi.tachidesk.manga.model.dataclass.IncludeInUpdate
import suwayomi.tachidesk.manga.model.dataclass.MangaChapterDataClass
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
import suwayomi.tachidesk.manga.model.dataclass.PaginatedList
import suwayomi.tachidesk.server.JavalinSetup.future
import suwayomi.tachidesk.server.util.formParam
import suwayomi.tachidesk.server.util.handler
@@ -115,7 +119,7 @@ object UpdateController {
updater.addMangasToQueue(
mangasToUpdate
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER, MangaDataClass::title)),
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER, MangaDataClass::title))
)
}

View File

@@ -24,6 +24,7 @@ import mu.KotlinLogging
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.graphql.subscriptions.downloadSubscriptionSource
import suwayomi.tachidesk.manga.impl.download.model.DownloadChapter
import suwayomi.tachidesk.manga.impl.download.model.DownloadState.Downloading
import suwayomi.tachidesk.manga.impl.download.model.DownloadStatus
@@ -100,6 +101,9 @@ object DownloadManager {
notifyFlow.emit(Unit)
}
}
/*if (downloadChapter != null) { TODO GRAPHQL
downloadSubscriptionSource.publish(downloadChapter)
}*/
}
private fun getStatus(): DownloadStatus {
@@ -234,6 +238,7 @@ object DownloadManager {
manga
)
downloadQueue.add(downloadChapter)
downloadSubscriptionSource.publish(downloadChapter)
logger.debug { "Added chapter ${chapter.id} to download queue (${manga.title} | ${chapter.name})" }
return downloadChapter
}

View File

@@ -24,6 +24,7 @@ import org.kodein.di.DI
import org.kodein.di.conf.global
import org.kodein.di.instance
import suwayomi.tachidesk.global.GlobalAPI
import suwayomi.tachidesk.graphql.GraphQL
import suwayomi.tachidesk.manga.MangaAPI
import suwayomi.tachidesk.server.util.Browser
import suwayomi.tachidesk.server.util.setupWebInterface
@@ -109,6 +110,7 @@ object JavalinSetup {
GlobalAPI.defineEndpoints()
MangaAPI.defineEndpoints()
}
GraphQL.defineEndpoints()
}
}

View File

@@ -0,0 +1,103 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>GraphiQL</title>
<style>
body {
height: 100%;
margin: 0;
width: 100%;
overflow: hidden;
}
#graphiql {
height: 100vh;
}
</style>
<!--
This GraphiQL example depends on Promise and fetch, which are available in
modern browsers, but can be "polyfilled" for older browsers.
GraphiQL itself depends on React DOM.
If you do not want to rely on a CDN, you can host these files locally or
include them directly in your favored resource bundler.
-->
<script
crossorigin="anonymous"
integrity="sha512-Vf2xGDzpqUOEIKO+X2rgTLWPY+65++WPwCHkX2nFMu9IcstumPsf/uKKRd5prX3wOu8Q0GBylRpsDB26R6ExOg=="
src="https://unpkg.com/react@17/umd/react.development.js"
></script>
<script
crossorigin="anonymous"
integrity="sha512-Wr9OKCTtq1anK0hq5bY3X/AvDI5EflDSAh0mE9gma+4hl+kXdTJPKZ3TwLMBcrgUeoY0s3dq9JjhCQc7vddtFg=="
src="https://unpkg.com/react-dom@17/umd/react-dom.development.js"
></script>
<!--
These two files can be found in the npm module, however you may wish to
copy them directly into your environment, or perhaps include them in your
favored resource bundler.
-->
<link href="https://unpkg.com/graphiql/graphiql.min.css" rel="stylesheet"/>
<link href="https://unpkg.com/@graphiql/plugin-explorer/dist/style.min.css" rel="stylesheet"/>
<style>
.doc-explorer-contents {
height: 100%;
padding-bottom: 40px;
}
.graphiql-explorer-root {
padding-bottom: 40px;
}
</style>
</head>
<body>
<div id="graphiql">Loading...</div>
<script
src="https://unpkg.com/graphiql/graphiql.min.js"
type="application/javascript"
></script>
<script
src="https://unpkg.com/@graphiql/plugin-explorer/dist/graphiql-plugin-explorer.umd.js"
type="application/javascript"
></script>
<script>
const fetcher = GraphiQL.createFetcher({
url: window.location.href,
});
function GraphiQLWithExplorer() {
const [query, setQuery] = React.useState(
'query AllCategories {\n' +
' categories {\n' +
' nodes {\n' +
' manga {\n' +
' nodes {\n' +
' title\n' +
' }\n' +
' }\n' +
' }\n' +
' }\n' +
'}',
);
const explorerPlugin = GraphiQLPluginExplorer.useExplorerPlugin({
query: query,
onEdit: setQuery,
});
return React.createElement(GraphiQL, {
fetcher: fetcher,
defaultEditorToolsVisibility: true,
plugins: [explorerPlugin],
query: query,
onEditQuery: setQuery,
});
}
ReactDOM.render(
React.createElement(GraphiQLWithExplorer),
document.getElementById('graphiql'),
);
</script>
</body>
</html>