refactor: Use DB instead of file to store custom manga info

This commit is contained in:
Ahmad Ansori Palembani 2024-06-05 07:29:04 +07:00
parent 716cc1fac8
commit 87b8430089
Signed by: null2264
GPG key ID: BA64F8B60AF3EFB6
16 changed files with 354 additions and 61 deletions

View file

@ -17,3 +17,4 @@ Please backup your data before updating to this version.
## Other
- Migrate to SQLDelight
- Custom manga info is now stored in the database

View file

@ -2,6 +2,7 @@ package dev.yokai.core.di
import dev.yokai.domain.extension.repo.ExtensionRepoRepository
import dev.yokai.data.extension.repo.ExtensionRepoRepositoryImpl
import dev.yokai.data.library.custom.CustomMangaRepositoryImpl
import dev.yokai.domain.extension.interactor.TrustExtension
import dev.yokai.domain.extension.repo.interactor.CreateExtensionRepo
import dev.yokai.domain.extension.repo.interactor.DeleteExtensionRepo
@ -9,6 +10,10 @@ import dev.yokai.domain.extension.repo.interactor.GetExtensionRepo
import dev.yokai.domain.extension.repo.interactor.GetExtensionRepoCount
import dev.yokai.domain.extension.repo.interactor.ReplaceExtensionRepo
import dev.yokai.domain.extension.repo.interactor.UpdateExtensionRepo
import dev.yokai.domain.library.custom.CustomMangaRepository
import dev.yokai.domain.library.custom.interactor.CreateCustomManga
import dev.yokai.domain.library.custom.interactor.DeleteCustomManga
import dev.yokai.domain.library.custom.interactor.GetCustomManga
import uy.kohesive.injekt.api.InjektModule
import uy.kohesive.injekt.api.InjektRegistrar
import uy.kohesive.injekt.api.addFactory
@ -17,6 +22,8 @@ import uy.kohesive.injekt.api.get
class DomainModule : InjektModule {
override fun InjektRegistrar.registerInjectables() {
addFactory { TrustExtension(get(), get()) }
addSingletonFactory<ExtensionRepoRepository> { ExtensionRepoRepositoryImpl(get()) }
addFactory { CreateExtensionRepo(get()) }
addFactory { DeleteExtensionRepo(get()) }
@ -25,6 +32,9 @@ class DomainModule : InjektModule {
addFactory { ReplaceExtensionRepo(get()) }
addFactory { UpdateExtensionRepo(get(), get()) }
addFactory { TrustExtension(get(), get()) }
addSingletonFactory<CustomMangaRepository> { CustomMangaRepositoryImpl(get()) }
addFactory { CreateCustomManga(get()) }
addFactory { DeleteCustomManga(get()) }
addFactory { GetCustomManga(get()) }
}
}

View file

@ -0,0 +1,75 @@
package dev.yokai.data.library.custom
import android.database.sqlite.SQLiteException
import dev.yokai.data.DatabaseHandler
import dev.yokai.domain.library.custom.CustomMangaRepository
import dev.yokai.domain.library.custom.exception.SaveCustomMangaException
import dev.yokai.domain.library.custom.model.CustomMangaInfo
import kotlinx.coroutines.flow.Flow
import timber.log.Timber
class CustomMangaRepositoryImpl(private val handler: DatabaseHandler) : CustomMangaRepository {
override fun subscribeAll(): Flow<List<CustomMangaInfo>> =
handler.subscribeToList { custom_manga_infoQueries.findAll(::mapCustomMangaInfo) }
override suspend fun getAll(): List<CustomMangaInfo> =
handler.awaitList { custom_manga_infoQueries.findAll(::mapCustomMangaInfo) }
override suspend fun insertCustomManga(
mangaId: Long,
title: String?,
author: String?,
artist: String?,
description: String?,
genre: String?,
status: Int?
) {
try {
handler.await { custom_manga_infoQueries.insert(mangaId, title, author, artist, description, genre, status?.toLong()) }
} catch (exc: SQLiteException) {
Timber.e(exc)
throw SaveCustomMangaException(exc)
}
}
override suspend fun insertBulkCustomManga(mangaList: List<CustomMangaInfo>) {
try {
handler.await(true) {
for (customMangaInfo in mangaList) {
custom_manga_infoQueries.insert(
customMangaInfo.mangaId,
customMangaInfo.title,
customMangaInfo.author,
customMangaInfo.artist,
customMangaInfo.description,
customMangaInfo.genre,
customMangaInfo.status?.toLong(),
)
}
}
} catch (exc: SQLiteException) {
Timber.e(exc)
throw SaveCustomMangaException(exc)
}
}
override suspend fun deleteCustomManga(mangaId: Long) =
handler.await { custom_manga_infoQueries.delete(mangaId) }
override suspend fun deleteBulkCustomManga(mangaIds: List<Long>) =
handler.await(true) {
for (mangaId in mangaIds) {
custom_manga_infoQueries.delete(mangaId)
}
}
private fun mapCustomMangaInfo(
mangaId: Long,
title: String?,
author: String?,
artist: String?,
description: String?,
genre: String?,
status: Long?
): CustomMangaInfo = CustomMangaInfo(mangaId, title, author, artist, description, genre, status?.toInt())
}

View file

@ -0,0 +1,31 @@
package dev.yokai.domain.library.custom
import dev.yokai.domain.library.custom.model.CustomMangaInfo
import kotlinx.coroutines.flow.Flow
interface CustomMangaRepository {
fun subscribeAll(): Flow<List<CustomMangaInfo>>
suspend fun getAll(): List<CustomMangaInfo>
suspend fun insertCustomManga(
mangaId: Long,
title: String? = null,
author: String? = null,
artist: String? = null,
description: String? = null,
genre: String? = null,
status: Int? = null,
)
suspend fun insertCustomManga(mangaInfo: CustomMangaInfo) =
insertCustomManga(
mangaInfo.mangaId,
mangaInfo.title,
mangaInfo.author,
mangaInfo.artist,
mangaInfo.description,
mangaInfo.genre,
mangaInfo.status,
)
suspend fun insertBulkCustomManga(mangaList: List<CustomMangaInfo>)
suspend fun deleteCustomManga(mangaId: Long)
suspend fun deleteBulkCustomManga(mangaIds: List<Long>)
}

View file

@ -0,0 +1,10 @@
package dev.yokai.domain.library.custom.exception
import java.io.IOException
/**
* Exception to abstract over SQLiteException and SQLiteConstraintException for multiplatform.
*
* @param throwable the source throwable to include for tracing.
*/
class SaveCustomMangaException(throwable: Throwable) : IOException("Error Saving Custom Manga Info to Database", throwable)

View file

@ -0,0 +1,32 @@
package dev.yokai.domain.library.custom.interactor
import dev.yokai.domain.library.custom.CustomMangaRepository
import dev.yokai.domain.library.custom.exception.SaveCustomMangaException
import dev.yokai.domain.library.custom.model.CustomMangaInfo
class CreateCustomManga(
private val customMangaRepository: CustomMangaRepository,
) {
suspend fun await(mangaInfo: CustomMangaInfo): Result {
try {
customMangaRepository.insertCustomManga(mangaInfo)
return Result.Success
} catch (exc: SaveCustomMangaException) {
return Result.Error
}
}
suspend fun bulk(mangaList: List<CustomMangaInfo>): Result {
try {
customMangaRepository.insertBulkCustomManga(mangaList)
return Result.Success
} catch (exc: SaveCustomMangaException) {
return Result.Error
}
}
sealed interface Result {
data object Success : Result
data object Error : Result
}
}

View file

@ -0,0 +1,10 @@
package dev.yokai.domain.library.custom.interactor
import dev.yokai.domain.library.custom.CustomMangaRepository
class DeleteCustomManga(
private val customMangaRepository: CustomMangaRepository,
) {
suspend fun await(mangaId: Long) = customMangaRepository.deleteCustomManga(mangaId)
suspend fun bulk(mangaIds: List<Long>) = customMangaRepository.deleteBulkCustomManga(mangaIds)
}

View file

@ -0,0 +1,11 @@
package dev.yokai.domain.library.custom.interactor
import dev.yokai.domain.library.custom.CustomMangaRepository
class GetCustomManga(
private val customMangaRepository: CustomMangaRepository,
) {
fun subscribeAll() = customMangaRepository.subscribeAll()
suspend fun getAll() = customMangaRepository.getAll()
}

View file

@ -0,0 +1,37 @@
package dev.yokai.domain.library.custom.model
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaImpl
data class CustomMangaInfo(
var mangaId: Long,
val title: String? = null,
val author: String? = null,
val artist: String? = null,
val description: String? = null,
val genre: String? = null,
val status: Int? = null,
) {
fun toManga() = MangaImpl().apply {
id = this@CustomMangaInfo.mangaId
title = this@CustomMangaInfo.title ?: ""
author = this@CustomMangaInfo.author
artist = this@CustomMangaInfo.artist
description = this@CustomMangaInfo.description
genre = this@CustomMangaInfo.genre
status = this@CustomMangaInfo.status ?: -1
}
companion object {
fun Manga.getMangaInfo() =
CustomMangaInfo(
mangaId = id!!,
title = title,
author = author,
artist = artist,
description = description,
genre = genre,
status = status,
)
}
}

View file

@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.backup
import android.content.Context
import android.net.Uri
import dev.yokai.domain.library.custom.model.CustomMangaInfo
import dev.yokai.domain.ui.settings.ReaderPreferences
import dev.yokai.domain.ui.settings.ReaderPreferences.CutoutBehaviour
import eu.kanade.tachiyomi.R
@ -37,6 +38,7 @@ import eu.kanade.tachiyomi.util.BackupUtil
import eu.kanade.tachiyomi.util.chapter.ChapterUtil
import eu.kanade.tachiyomi.util.manga.MangaUtil
import eu.kanade.tachiyomi.util.system.createFileInCacheDir
import eu.kanade.tachiyomi.util.system.launchNow
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.isActive
import kotlinx.serialization.protobuf.ProtoBuf
@ -198,7 +200,7 @@ class BackupRestorer(val context: Context, val notifier: BackupNotifier) {
tracks: List<Track>,
backupCategories: List<BackupCategory>,
filteredScanlators: List<String>,
customManga: CustomMangaManager.ComicList.ComicInfoYokai?,
customManga: CustomMangaInfo?,
) {
val fetchedManga = manga.also {
it.initialized = it.description != null
@ -218,7 +220,7 @@ class BackupRestorer(val context: Context, val notifier: BackupNotifier) {
tracks: List<Track>,
backupCategories: List<BackupCategory>,
filteredScanlators: List<String>,
customManga: CustomMangaManager.ComicList.ComicInfoYokai?,
customManga: CustomMangaInfo?,
) {
restoreChapters(backupManga, chapters)
restoreExtras(backupManga, categories, history, tracks, backupCategories, filteredScanlators, customManga)
@ -258,14 +260,18 @@ class BackupRestorer(val context: Context, val notifier: BackupNotifier) {
tracks: List<Track>,
backupCategories: List<BackupCategory>,
filteredScanlators: List<String>,
customManga: CustomMangaManager.ComicList.ComicInfoYokai?,
customManga: CustomMangaInfo?,
) {
restoreCategories(manga, categories, backupCategories)
restoreHistoryForManga(history)
restoreTrackForManga(manga, tracks)
restoreFilteredScanlatorsForManga(manga, filteredScanlators)
customManga?.id = manga.id!!
customManga?.let { customMangaManager.saveMangaInfo(it) }
customManga?.let {
it.mangaId = manga.id!!
launchNow {
customMangaManager.saveMangaInfo(it)
}
}
}
/**

View file

@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.backup.models
import dev.yokai.core.metadata.ComicInfo
import dev.yokai.core.metadata.ComicInfoPublishingStatus
import dev.yokai.domain.library.custom.model.CustomMangaInfo
import eu.kanade.tachiyomi.data.database.models.ChapterImpl
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaImpl
@ -85,7 +86,7 @@ data class BackupManga(
}
}
fun getCustomMangaInfo(): CustomMangaManager.ComicList.ComicInfoYokai? {
fun getCustomMangaInfo(): CustomMangaInfo? {
if (customTitle != null ||
customArtist != null ||
customAuthor != null ||
@ -93,13 +94,13 @@ data class BackupManga(
customGenre != null ||
customStatus != 0
) {
return CustomMangaManager.ComicList.ComicInfoYokai.create(
id = 0L,
return CustomMangaInfo(
mangaId= 0L,
title = customTitle,
author = customAuthor,
artist = customArtist,
description = customDescription,
genre = customGenre?.toTypedArray(),
genre = customGenre?.joinToString(),
status = customStatus,
)
}

View file

@ -6,9 +6,16 @@ import dev.yokai.core.metadata.COMIC_INFO_EDITS_FILE
import dev.yokai.core.metadata.ComicInfo
import dev.yokai.core.metadata.ComicInfoPublishingStatus
import dev.yokai.core.metadata.copyFromComicInfo
import dev.yokai.domain.library.custom.interactor.CreateCustomManga
import dev.yokai.domain.library.custom.interactor.DeleteCustomManga
import dev.yokai.domain.library.custom.interactor.GetCustomManga
import dev.yokai.domain.library.custom.model.CustomMangaInfo
import dev.yokai.domain.library.custom.model.CustomMangaInfo.Companion.getMangaInfo
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaImpl
import eu.kanade.tachiyomi.util.system.writeText
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
@ -17,44 +24,46 @@ import nl.adaptivity.xmlutil.serialization.XML
import nl.adaptivity.xmlutil.serialization.XmlSerialName
import nl.adaptivity.xmlutil.serialization.XmlValue
import uy.kohesive.injekt.injectLazy
import java.io.InputStream
import java.nio.charset.StandardCharsets
class CustomMangaManager(val context: Context) {
private val scope = CoroutineScope(Dispatchers.IO)
private val xml: XML by injectLazy()
private val externalDir = UniFile.fromFile(context.getExternalFilesDir(null))
private var removedCustomManga = mutableListOf<Long>()
private var customMangaMap = mutableMapOf<Long, Manga>()
private val createCustomManga: CreateCustomManga by injectLazy()
private val deleteCustomManga: DeleteCustomManga by injectLazy()
private val getCustomManga: GetCustomManga by injectLazy()
init {
fetchCustomData()
scope.launch {
fetchCustomData()
}
}
companion object {
const val EDIT_JSON_FILE = "edits.json"
fun Manga.toComicInfo(): ComicList.ComicInfoYokai {
return ComicList.ComicInfoYokai.create(
id = id!!,
title = title,
author = author,
artist = artist,
description = description,
genre = genre.orEmpty(),
status = status,
)
}
}
fun getManga(manga: Manga): Manga? = customMangaMap[manga.id]
private fun fetchCustomData() {
private suspend fun fetchCustomData() {
val comicInfoEdits = externalDir?.findFile(COMIC_INFO_EDITS_FILE)
val editJson = externalDir?.findFile(EDIT_JSON_FILE)
val dbMangaInfo = getCustomManga.getAll()
customMangaMap = dbMangaInfo.associate { info ->
val id = info.mangaId
id to info.toManga()
}.toMutableMap()
if (comicInfoEdits != null && comicInfoEdits.exists() && comicInfoEdits.isFile) {
fetchFromComicInfo(comicInfoEdits.openInputStream())
fetchFromComicInfo(comicInfoEdits)
return
}
@ -65,20 +74,22 @@ class CustomMangaManager(val context: Context) {
}
}
private fun fetchFromComicInfo(stream: InputStream) {
val comicInfoEdits = AndroidXmlReader(stream, StandardCharsets.UTF_8.name()).use {
private suspend fun fetchFromComicInfo(comicInfoFile: UniFile) {
val comicInfoEdits = AndroidXmlReader(comicInfoFile.openInputStream(), StandardCharsets.UTF_8.name()).use {
xml.decodeFromReader<ComicList>(it)
}
if (comicInfoEdits.comics == null) return
customMangaMap = comicInfoEdits.comics.mapNotNull { obj ->
comicInfoEdits.comics.mapNotNull { obj ->
val id = obj.id ?: return@mapNotNull null
id to mangaFromComicInfoObject(id, obj.value)
}.toMap().toMutableMap()
customMangaMap[id] = mangaFromComicInfoObject(id, obj.value)
}
saveCustomInfo { comicInfoFile.delete() }
}
private fun fetchFromLegacyJson(jsonFile: UniFile) {
private suspend fun fetchFromLegacyJson(jsonFile: UniFile) {
val json = try {
Json.decodeFromStream<MangaList>(jsonFile.openInputStream())
} catch (e: Exception) {
@ -86,36 +97,41 @@ class CustomMangaManager(val context: Context) {
} ?: return
val mangasJson = json.mangas ?: return
customMangaMap = mangasJson.mapNotNull { mangaObject ->
mangasJson.mapNotNull { mangaObject ->
val id = mangaObject.id ?: return@mapNotNull null
id to mangaObject.toManga()
}.toMap().toMutableMap()
customMangaMap[id] = mangaObject.toManga()
}
saveCustomInfo { jsonFile.delete() }
}
fun saveMangaInfo(manga: ComicList.ComicInfoYokai) {
val mangaId = manga.id ?: return
if (manga.value.series == null &&
manga.value.writer == null &&
manga.value.penciller == null &&
manga.value.summary == null &&
manga.value.genre == null &&
(manga.value.publishingStatus?.value ?: "Invalid") == "Invalid"
suspend fun saveMangaInfo(manga: CustomMangaInfo) {
val mangaId = manga.mangaId ?: return
if (manga.title == null &&
manga.author == null &&
manga.artist == null &&
manga.description == null &&
manga.genre == null &&
(manga.status ?: -1) == -1
) {
customMangaMap.remove(mangaId)
customMangaMap[mangaId]?.let {
removedCustomManga.add(mangaId)
customMangaMap.remove(mangaId)
}
} else {
customMangaMap[mangaId] = mangaFromComicInfoObject(mangaId, manga.value)
customMangaMap[mangaId] = manga.toManga()
}
saveCustomInfo()
}
private fun saveCustomInfo(onComplete: () -> Unit = {}) {
val comicInfoEdits = externalDir?.createFile(COMIC_INFO_EDITS_FILE) ?: return
private suspend fun saveCustomInfo(onComplete: () -> Unit = {}) {
deleteCustomManga.bulk(removedCustomManga)
removedCustomManga = mutableListOf()
val edits = customMangaMap.values.map { it.toComicInfo() }
if (edits.isNotEmpty() && comicInfoEdits.exists()) {
comicInfoEdits.writeText(xml.encodeToString(ComicList.serializer(), ComicList(edits)), onComplete = onComplete)
val edits = customMangaMap.values.map { it.getMangaInfo() }
if (edits.isNotEmpty()) {
createCustomManga.bulk(edits)
onComplete()
}
}

View file

@ -10,6 +10,7 @@ import coil3.request.CachePolicy
import coil3.request.ImageRequest
import coil3.request.SuccessResult
import com.hippo.unifile.UniFile
import dev.yokai.domain.library.custom.model.CustomMangaInfo
import dev.yokai.domain.storage.StorageManager
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.R
@ -55,6 +56,7 @@ import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.system.ImageUtil
import eu.kanade.tachiyomi.util.system.executeOnIO
import eu.kanade.tachiyomi.util.system.launchIO
import eu.kanade.tachiyomi.util.system.launchNow
import eu.kanade.tachiyomi.util.system.launchUI
import eu.kanade.tachiyomi.util.system.withUIContext
import eu.kanade.tachiyomi.widget.TriStateCheckBox
@ -712,13 +714,13 @@ class MangaDetailsPresenter(
fun confirmDeletion() {
launchIO {
coverCache.deleteFromCache(manga)
customMangaManager.saveMangaInfo(CustomMangaManager.ComicList.ComicInfoYokai.create(
id = manga.id!!,
customMangaManager.saveMangaInfo(CustomMangaInfo(
mangaId = manga.id!!,
title = null,
author = null,
artist = null,
description = null,
genre = null as String?,
genre = null,
status = null,
))
downloadManager.deleteManga(manga, source)
@ -801,20 +803,22 @@ class MangaDetailsPresenter(
null
}
if (seriesType != null) {
genre = setSeriesType(seriesType, genre?.joinToString(", "))
genre = setSeriesType(seriesType, genre?.joinToString())
manga.viewer_flags = -1
db.updateViewerFlags(manga).executeAsBlocking()
}
val manga = CustomMangaManager.ComicList.ComicInfoYokai.create(
id = manga.id!!,
val manga = CustomMangaInfo(
mangaId = manga.id!!,
title?.trimOrNull(),
author?.trimOrNull(),
artist?.trimOrNull(),
description?.trimOrNull(),
genre,
genre?.joinToString(),
if (status != this.manga.originalStatus) status else null,
)
customMangaManager.saveMangaInfo(manga)
launchNow {
customMangaManager.saveMangaInfo(manga)
}
}
if (uri != null) {
editCoverWithStream(uri)

View file

@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.ui.migration.manga.process
import android.view.MenuItem
import dev.yokai.domain.library.custom.model.CustomMangaInfo.Companion.getMangaInfo
import dev.yokai.domain.ui.UiPreferences
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.data.cache.CoverCache
@ -9,13 +10,13 @@ import eu.kanade.tachiyomi.data.database.models.History
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaCategory
import eu.kanade.tachiyomi.data.library.CustomMangaManager
import eu.kanade.tachiyomi.data.library.CustomMangaManager.Companion.toComicInfo
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.track.EnhancedTrackService
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.ui.migration.MigrationFlags
import eu.kanade.tachiyomi.util.system.launchNow
import eu.kanade.tachiyomi.util.system.launchUI
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
@ -229,7 +230,9 @@ class MigrationProcessAdapter(
}
customMangaManager.getManga(prevManga)?.let { customManga ->
customManga.id = manga.id!!
customMangaManager.saveMangaInfo(customManga.toComicInfo())
launchNow {
customMangaManager.saveMangaInfo(customManga.getMangaInfo())
}
}
}

View file

@ -0,0 +1,34 @@
CREATE TABLE custom_manga_info (
manga_id INTEGER NOT NULL PRIMARY KEY,
title TEXT,
author TEXT,
artist TEXT,
description TEXT,
genre TEXT,
status INTEGER,
UNIQUE (manga_id) ON CONFLICT REPLACE,
FOREIGN KEY(manga_id) REFERENCES mangas (_id)
ON DELETE CASCADE
);
findAll:
SELECT *
FROM custom_manga_info;
insert:
INSERT INTO custom_manga_info(manga_id, title, author, artist, description, genre, status)
VALUES (:manga_id, :title, :author, :artist, :description, :genre, :status)
ON CONFLICT (manga_id)
DO UPDATE
SET
title = :title,
author = :author,
artist = :artist,
description = :description,
genre = :genre,
status = :status
WHERE manga_id = :manga_id;
delete:
DELETE FROM custom_manga_info
WHERE manga_id = :manga_id;

View file

@ -0,0 +1,12 @@
CREATE TABLE custom_manga_info (
manga_id INTEGER NOT NULL PRIMARY KEY,
title TEXT,
author TEXT,
artist TEXT,
description TEXT,
genre TEXT,
status INTEGER,
UNIQUE (manga_id) ON CONFLICT REPLACE,
FOREIGN KEY(manga_id) REFERENCES mangas (_id)
ON DELETE CASCADE
);