From 2b03ffd1acca226e885f291ce06563a568ab3d38 Mon Sep 17 00:00:00 2001 From: "Andres Gomez, Thomas (ITDV RL)" Date: Sun, 23 Jun 2024 08:42:09 +0200 Subject: [PATCH] Properly handle update / insert and delete in the adventure database. --- .../2.json | 8 +- .../data/database/adventure/AdventureDao.kt | 71 ++-- .../data/database/adventure/AdventureDbo.kt | 2 +- .../adventure/AdventureRepository.kt | 347 +++++++++--------- .../screens/adventure/book/AdventureBook.kt | 3 +- .../adventure/book/AdventureBooksScreen.kt | 5 +- .../adventure/detail/AdventureDetailScreen.kt | 5 +- 7 files changed, 230 insertions(+), 211 deletions(-) diff --git a/app/schemas/com.pixelized.rplexicon.data.database.CompanionDatabase/2.json b/app/schemas/com.pixelized.rplexicon.data.database.CompanionDatabase/2.json index f6fa897..1a29f1c 100644 --- a/app/schemas/com.pixelized.rplexicon.data.database.CompanionDatabase/2.json +++ b/app/schemas/com.pixelized.rplexicon.data.database.CompanionDatabase/2.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 2, - "identityHash": "95cb578b3b61a022ab4dda676d2e4645", + "identityHash": "f1496aa4aa95e44822a7b0650065c53e", "entities": [ { "tableName": "lexicon", @@ -353,7 +353,7 @@ }, { "tableName": "AdventureStory", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`title` TEXT NOT NULL, `category` TEXT, `background` TEXT, `revision` INTEGER NOT NULL, `index` INTEGER NOT NULL, `documentId` TEXT NOT NULL, PRIMARY KEY(`title`, `documentId`), FOREIGN KEY(`documentId`) REFERENCES `AdventureBooks`(`documentId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`title` TEXT NOT NULL, `category` TEXT, `background` TEXT, `revision` INTEGER NOT NULL, `index` INTEGER NOT NULL, `documentId` TEXT NOT NULL, PRIMARY KEY(`title`, `documentId`), FOREIGN KEY(`documentId`) REFERENCES `AdventureBooks`(`documentId`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "title", @@ -403,7 +403,7 @@ "foreignKeys": [ { "table": "AdventureBooks", - "onDelete": "NO ACTION", + "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "documentId" @@ -478,7 +478,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '95cb578b3b61a022ab4dda676d2e4645')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f1496aa4aa95e44822a7b0650065c53e')" ] } } \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/data/database/adventure/AdventureDao.kt b/app/src/main/java/com/pixelized/rplexicon/data/database/adventure/AdventureDao.kt index c5c3ec6..31979a4 100644 --- a/app/src/main/java/com/pixelized/rplexicon/data/database/adventure/AdventureDao.kt +++ b/app/src/main/java/com/pixelized/rplexicon/data/database/adventure/AdventureDao.kt @@ -6,6 +6,7 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction +import androidx.room.Update import kotlinx.coroutines.flow.Flow @Dao @@ -21,64 +22,70 @@ interface AdventureDao { fun getStoryLinesFlow(): Flow> @Query("SELECT * from ${AdventureBookDbo.TABLE}") - fun findBooks(): List + fun fetchAdventureBooks(): List @Query("SELECT * from ${AdventureStoryDbo.TABLE}") - fun findStories(): List + fun fetchAdventureStories(): List @Query("SELECT * from ${AdventureStoryDbo.TABLE} where documentId = :documentId") - fun findStories(documentId: String): List + fun fetchAdventureStories(documentId: String): List @Query("SELECT * from ${AdventureStoryDbo.TABLE} where documentId = :documentId AND title = :title") - fun findStory(documentId: String, title: String): AdventureStoryDbo? + fun fetchAdventureStory(documentId: String, title: String): AdventureStoryDbo? - @Insert(onConflict = OnConflictStrategy.IGNORE) + @Query("SELECT * from ${AdventureLineDbo.TABLE} where documentId = :documentId AND title = :storyTitle") + fun fetchAdventureLine(documentId: String, storyTitle: String): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) fun insertBook(book: AdventureBookDbo) - @Insert(onConflict = OnConflictStrategy.REPLACE) - fun insertStory(story: AdventureStoryDbo) - - @Insert(onConflict = OnConflictStrategy.REPLACE) - fun insertLines(line: AdventureLineDbo) + @Update + fun updateBook(book: AdventureBookDbo) @Delete(entity = AdventureBookDbo::class) fun deleteBook(id: AdventureBookDbo.BookId) - @Delete(entity = AdventureStoryDbo::class) - fun deleteStory(id: AdventureBookDbo.BookId) + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertStory(story: AdventureStoryDbo) + + @Update + fun updateStory(story: AdventureStoryDbo) @Delete(entity = AdventureStoryDbo::class) fun deleteStory(id: AdventureStoryDbo.StoryId) + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertLine(line: AdventureLineDbo) + + @Update + fun updateLine(line: AdventureLineDbo) + @Delete(entity = AdventureLineDbo::class) fun deleteLines(id: AdventureLineDbo.LineId) @Transaction fun update( - books: List, + booksToInsert: List, + booksToUpdate: List, booksToRemove: List, - stories: List, + storiesToInsert: List, + storiesToUpdate: List, storiesToRemove: List, - lines: List, + linesToInsert: List, + linesToUpdate: List, linesToRemove: List, ) { - // First clean the database from old unused data. - booksToRemove.forEach { - deleteStory(id = it) - deleteBook(id = it) - } - // StoryLineDbo are remove with cascading foreign key from StoryBdo. - storiesToRemove.forEach { deleteStory(id = it) } + // First remove the stuff linesToRemove.forEach { deleteLines(id = it) } - - books.forEach { book -> - insertBook(book = book) - } - stories.forEach { story -> - insertStory(story = story) - } - lines.forEach { line -> - insertLines(line = line) - } + storiesToRemove.forEach { deleteStory(id = it) } + booksToRemove.forEach { deleteBook(id = it) } + // then insert the stuff + booksToInsert.forEach { insertBook(book = it) } + storiesToInsert.forEach { insertStory(story = it) } + linesToInsert.forEach { insertLine(line = it) } + // and update the stuff + booksToUpdate.forEach { updateBook(book = it) } + storiesToUpdate.forEach { updateStory(story = it) } + linesToUpdate.forEach { updateLine(line = it) } } } \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/data/database/adventure/AdventureDbo.kt b/app/src/main/java/com/pixelized/rplexicon/data/database/adventure/AdventureDbo.kt index 157db52..dfa66e4 100644 --- a/app/src/main/java/com/pixelized/rplexicon/data/database/adventure/AdventureDbo.kt +++ b/app/src/main/java/com/pixelized/rplexicon/data/database/adventure/AdventureDbo.kt @@ -44,7 +44,7 @@ data class AdventureBookDbo( entity = AdventureBookDbo::class, parentColumns = [AdventureBookDbo.DOCUMENT_ID], childColumns = [AdventureStoryDbo.FK_DOCUMENT_ID], - onDelete = ForeignKey.NO_ACTION, + onDelete = ForeignKey.CASCADE, ) ], ) diff --git a/app/src/main/java/com/pixelized/rplexicon/data/repository/adventure/AdventureRepository.kt b/app/src/main/java/com/pixelized/rplexicon/data/repository/adventure/AdventureRepository.kt index 9814b2d..a85a33b 100644 --- a/app/src/main/java/com/pixelized/rplexicon/data/repository/adventure/AdventureRepository.kt +++ b/app/src/main/java/com/pixelized/rplexicon/data/repository/adventure/AdventureRepository.kt @@ -5,9 +5,6 @@ import com.pixelized.rplexicon.data.database.adventure.AdventureBookDbo import com.pixelized.rplexicon.data.database.adventure.AdventureLineDbo import com.pixelized.rplexicon.data.database.adventure.AdventureStoryDbo import com.pixelized.rplexicon.data.model.adventure.Adventure -import com.pixelized.rplexicon.data.model.adventure.AdventureBook -import com.pixelized.rplexicon.data.model.adventure.AdventureLine -import com.pixelized.rplexicon.data.model.adventure.AdventureStory import com.pixelized.rplexicon.data.parser.adventure.AdventureBookParser import com.pixelized.rplexicon.data.parser.adventure.AdventureStoryLineParser import com.pixelized.rplexicon.data.parser.adventure.AdventureStoryParser @@ -17,24 +14,30 @@ import com.pixelized.rplexicon.utilitary.exceptions.IncompatibleSheetStructure import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.withContext import javax.inject.Inject import javax.inject.Singleton +import kotlin.math.min @Singleton class AdventureRepository @Inject constructor( private val googleRepository: GoogleSheetServiceRepository, - private val database: CompanionDatabase, private val adventureBookParser: AdventureBookParser, private val adventureStoryParser: AdventureStoryParser, private val adventureStoryLineParser: AdventureStoryLineParser, private val adventureDboFactory: AdventureDboFactory, + companionDatabase: CompanionDatabase, ) { - private val adventures = database.adventureDao().let { database -> + private val database = companionDatabase.adventureDao() + private val service = Service() + + private val adventures = database.let { database -> combine( database.getBooksFlow(), database.getStoriesFlow(), @@ -56,9 +59,7 @@ class AdventureRepository @Inject constructor( return adventures } - fun storyFlow( - bookTitle: String, - ): Flow> { + fun storyFlow(bookTitle: String): Flow> { return adventures.map { adventures -> adventures.filter { adventure -> adventure.bookTitle == bookTitle @@ -66,10 +67,7 @@ class AdventureRepository @Inject constructor( } } - fun adventureFlow( - documentId: String, - adventureTitle: String, - ): Flow { + fun adventureFlow(documentId: String, adventureTitle: String): Flow { return adventures.map { adventures -> adventures.firstOrNull { adventure -> adventure.documentId == documentId && adventure.storyTitle == adventureTitle @@ -78,188 +76,201 @@ class AdventureRepository @Inject constructor( } @Throws(IncompatibleSheetStructure::class, Exception::class) - suspend fun fetchBooks() { - val database = database.adventureDao() + suspend fun fetchBooks() = update { + fetchAndCompareBooks() - val booksToRemove = database.findBooks() - .map { it.id } - .toMutableList() - - val books: List = fetchAdventureBooks().map { book -> - // convert to BookDbo. - adventureDboFactory.convertToDbo(book = book).also { - // Flag this book to not delete it - booksToRemove.remove(element = it.id) - } + (booksToInsert + booksToUpdate).forEach { book -> + fetchAndCompareStories(documentId = book.documentId) } - val storiesToRemove = books - .flatMap { book -> database.findStories(documentId = book.documentId) } - .map { it.id } - .toMutableList() - - val stories: List = books.flatMap { book -> - fetchAdventureStory(documentId = book.documentId) - .mapIndexed { index, story -> - // convert to StoryBdo - val update = adventureDboFactory.convertToDbo( - story = story, - index = index, - documentId = book.documentId, - ).also { - // Flag this story to not delete it - storiesToRemove.remove(element = it.id) - } - val cache = database.findStory( - documentId = book.documentId, - title = story.title, - ) - when { - cache == null -> update - cache.revision < update.revision -> update - else -> null - } - } - .mapNotNull { it } + (storiesToInsert + storiesToUpdate).forEach { story -> + fetchAndCompareLines(documentId = story.documentId, storyTitle = story.title) } - - val lines: List = stories.flatMap { story -> - fetchAdventureLine( - documentId = story.documentId, - storyTitle = story.title, - ).mapIndexed { index, line -> - adventureDboFactory.convertToDbo( - adventureLine = line, - index = index, - documentId = story.documentId, - storyTitle = story.title, - ) - } - } - - database.update( - booksToRemove = booksToRemove, - storiesToRemove = storiesToRemove, - linesToRemove = lines.groupBy { it.id }.keys.toList(), - books = books, - stories = stories, - lines = lines, - ) } @Throws(IncompatibleSheetStructure::class, Exception::class) suspend fun fetchStories( documentId: String, - ) { - val database = database.adventureDao() + ) = update { + fetchAndCompareStories(documentId = documentId) - val storiesToRemove = database.findStories(documentId = documentId) - .map { it.id } - .toMutableList() - - val stories: List = fetchAdventureStory(documentId = documentId) - .mapIndexed { index, story -> - // convert to StoryBdo - val update = adventureDboFactory.convertToDbo( - story = story, - index = index, - documentId = documentId, - ).also { - // Flag this story to not delete it - storiesToRemove.remove(element = it.id) - } - val cache = database.findStory( - documentId = documentId, - title = story.title, - ) - when { - cache == null -> update - cache.revision < update.revision -> update - else -> null - } - } - .mapNotNull { it } - - val lines: List = stories.flatMap { story -> - fetchAdventureLine( - documentId = story.documentId, - storyTitle = story.title, - ).mapIndexed { index, line -> - adventureDboFactory.convertToDbo( - adventureLine = line, - index = index, - documentId = story.documentId, - storyTitle = story.title, - ) - } + (storiesToInsert + storiesToUpdate).forEach { story -> + fetchAndCompareLines(documentId = story.documentId, storyTitle = story.title) } - - database.update( - booksToRemove = emptyList(), - storiesToRemove = storiesToRemove, - linesToRemove = lines.groupBy { it.id }.keys.toList(), - books = emptyList(), - stories = stories, - lines = lines, - ) } @Throws(IncompatibleSheetStructure::class, Exception::class) suspend fun fetchAdventure( documentId: String, storyTitle: String, - ) { - val database = database.adventureDao() - - val lines = fetchAdventureLine( + ) = update { + // specific case for adventure fetching, need to update the story too as the background is stored there. + service.fetchAdventureStories( documentId = documentId, - storyTitle = storyTitle, - ).mapIndexed { index, line -> - adventureDboFactory.convertToDbo( - adventureLine = line, - index = index, - documentId = documentId, - storyTitle = storyTitle, - ) + ).firstOrNull { story -> + story.documentId == documentId && story.title == storyTitle + }?.let { story -> + storiesToUpdate.add(story) } + fetchAndCompareLines(documentId = documentId, storyTitle = storyTitle) + } + + private suspend fun DatabaseUpdateScope.fetchAndCompareBooks() { + val cache = database.fetchAdventureBooks() + val update = service.fetchAdventureBooks() + + val toRemove = cache.map { it.id }.toMutableList() + val toInsert = mutableListOf() + val toUpdate = mutableListOf() + + update.forEach { item -> + when (toRemove.remove(item.id)) { + true -> toUpdate.add(item) + else -> toInsert.add(item) + } + } + + booksToInsert.addAll(elements = toInsert) + booksToUpdate.addAll(elements = toUpdate) + booksToRemove.addAll(elements = toRemove) + } + + private suspend fun DatabaseUpdateScope.fetchAndCompareStories( + documentId: String, + ) { + val cache = database.fetchAdventureStories( + documentId = documentId, + ).associateBy { + it.id + } + val update = service.fetchAdventureStories( + documentId = documentId, + ) + + val toRemove = cache.keys.toMutableList() + val toInsert = mutableListOf() + val toUpdate = mutableListOf() + + update.forEach { item -> + when (toRemove.remove(item.id)) { + true -> if ((cache[item.id]?.revision ?: 0) < item.revision) toUpdate.add(item) + else -> toInsert.add(item) + } + } + + storiesToInsert.addAll(elements = toInsert) + storiesToUpdate.addAll(elements = toUpdate) + storiesToRemove.addAll(elements = toRemove) + } + + private suspend fun DatabaseUpdateScope.fetchAndCompareLines( + documentId: String, + storyTitle: String, + ) { + val cache = database.fetchAdventureLine( + documentId = documentId, + storyTitle = storyTitle, + ) + val update = service.fetchAdventureLine( + documentId = documentId, + storyTitle = storyTitle, + ) + // if cache is smaller than the update we need to insert + if (cache.size < update.size) { + (cache.size until update.size).forEach { + linesToInsert.add(update[it]) + } + } + // if cache is bigger than the update we need to delete + if (update.size < cache.size) { + (update.size until cache.size).forEach { + linesToRemove.add(cache[it].id) + } + } + // then we update the rest + (0 until min(cache.size, update.size)).forEach { + linesToUpdate.add(update[it]) + } + } + + private suspend inline fun update( + crossinline lambda: suspend DatabaseUpdateScope.() -> Unit, + ) = withContext(Dispatchers.IO + NonCancellable) { + val update = DatabaseUpdateScope() + + lambda.invoke(update) + database.update( - booksToRemove = emptyList(), - storiesToRemove = emptyList(), - linesToRemove = emptyList(), - books = emptyList(), - stories = emptyList(), - lines = lines, + booksToInsert = update.booksToInsert, + booksToUpdate = update.booksToUpdate, + booksToRemove = update.booksToRemove, + storiesToInsert = update.storiesToInsert, + storiesToUpdate = update.storiesToUpdate, + storiesToRemove = update.storiesToRemove, + linesToInsert = update.linesToInsert, + linesToUpdate = update.linesToUpdate, + linesToRemove = update.linesToRemove, ) } - @Throws(IncompatibleSheetStructure::class, Exception::class) - private suspend fun fetchAdventureBooks(): List { - return googleRepository.fetch { sheet -> - val request = sheet.get(Adventures.ID, Adventures.ADVENTURES) - adventureBookParser.parse(sheet = request.execute()) - } - } + private data class DatabaseUpdateScope( + val booksToInsert: MutableList = mutableListOf(), + val booksToUpdate: MutableList = mutableListOf(), + val booksToRemove: MutableList = mutableListOf(), + val storiesToInsert: MutableList = mutableListOf(), + val storiesToUpdate: MutableList = mutableListOf(), + val storiesToRemove: MutableList = mutableListOf(), + val linesToInsert: MutableList = mutableListOf(), + val linesToUpdate: MutableList = mutableListOf(), + val linesToRemove: MutableList = mutableListOf(), + ) - @Throws(IncompatibleSheetStructure::class, Exception::class) - private suspend fun fetchAdventureStory(documentId: String): List { - return googleRepository.fetch { sheet -> - val request = sheet.get(documentId, Adventures.ADVENTURES) - adventureStoryParser.parse(sheet = request.execute()) - } - } - - private suspend fun fetchAdventureLine( - documentId: String, - storyTitle: String, - ): List { - return try { - googleRepository.fetch { sheet -> - val request = sheet.get(documentId, storyTitle) - adventureStoryLineParser.parse(sheet = request.execute()) + private inner class Service { + @Throws(IncompatibleSheetStructure::class, Exception::class) + suspend fun fetchAdventureBooks(): List { + return googleRepository.fetch { sheet -> + val request = sheet.get(Adventures.ID, Adventures.ADVENTURES) + adventureBookParser.parse(sheet = request.execute()) + }.map { book -> + adventureDboFactory.convertToDbo(book = book) + } + } + + @Throws(IncompatibleSheetStructure::class, Exception::class) + suspend fun fetchAdventureStories(documentId: String): List { + return googleRepository.fetch { sheet -> + val request = sheet.get(documentId, Adventures.ADVENTURES) + adventureStoryParser.parse(sheet = request.execute()) + }.mapIndexed { index, story -> + adventureDboFactory.convertToDbo( + story = story, + index = index, + documentId = documentId, + ) + } + } + + @Throws(Exception::class) + suspend fun fetchAdventureLine( + documentId: String, + storyTitle: String, + ): List { + return try { + googleRepository.fetch { sheet -> + val request = sheet.get(documentId, storyTitle) + adventureStoryLineParser.parse(sheet = request.execute()) + } + } catch (exception: Exception) { + emptyList() + }.mapIndexed { index, line -> + adventureDboFactory.convertToDbo( + adventureLine = line, + index = index, + documentId = documentId, + storyTitle = storyTitle, + ) } - } catch (exception: Exception) { - emptyList() } } } \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/screens/adventure/book/AdventureBook.kt b/app/src/main/java/com/pixelized/rplexicon/ui/screens/adventure/book/AdventureBook.kt index f1382ff..a56f4b2 100644 --- a/app/src/main/java/com/pixelized/rplexicon/ui/screens/adventure/book/AdventureBook.kt +++ b/app/src/main/java/com/pixelized/rplexicon/ui/screens/adventure/book/AdventureBook.kt @@ -21,8 +21,8 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.pixelized.rplexicon.ui.composable.images.AsyncImage import com.pixelized.rplexicon.ui.composable.images.BackgroundImage +import com.pixelized.rplexicon.ui.composable.images.rememberBackgroundGradient import com.pixelized.rplexicon.ui.theme.LexiconTheme import com.pixelized.rplexicon.utilitary.annotateWithDropCap import com.pixelized.rplexicon.utilitary.extentions.lexicon @@ -53,6 +53,7 @@ fun AdventureBook( .matchParentSize() .align(alignment = Alignment.TopCenter), colorFilter = null, + background = rememberBackgroundGradient(0.2f, 1.0f), contentScale = ContentScale.FillWidth, alignment = Alignment.TopCenter, model = item.bookIcon, diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/screens/adventure/book/AdventureBooksScreen.kt b/app/src/main/java/com/pixelized/rplexicon/ui/screens/adventure/book/AdventureBooksScreen.kt index fd4a7f9..ec976cd 100644 --- a/app/src/main/java/com/pixelized/rplexicon/ui/screens/adventure/book/AdventureBooksScreen.kt +++ b/app/src/main/java/com/pixelized/rplexicon/ui/screens/adventure/book/AdventureBooksScreen.kt @@ -122,7 +122,10 @@ private fun AdventureListContent( horizontalArrangement = Arrangement.spacedBy(space = 8.dp), verticalArrangement = Arrangement.spacedBy(space = 8.dp), ) { - items(items = items.value) { + items( + items = items.value, + key = { it.documentId }, + ) { AdventureBook( modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/screens/adventure/detail/AdventureDetailScreen.kt b/app/src/main/java/com/pixelized/rplexicon/ui/screens/adventure/detail/AdventureDetailScreen.kt index 2d68133..7951a90 100644 --- a/app/src/main/java/com/pixelized/rplexicon/ui/screens/adventure/detail/AdventureDetailScreen.kt +++ b/app/src/main/java/com/pixelized/rplexicon/ui/screens/adventure/detail/AdventureDetailScreen.kt @@ -194,10 +194,7 @@ private fun AdventureDetailContent( style = MaterialTheme.lexicon.typography.base.titleLarge, overflow = TextOverflow.Ellipsis, maxLines = 1, - text = annotateMajWithDropCap( - text = title.value ?: "", - style = MaterialTheme.lexicon.typography.dropCap.titleLarge - ), + text = title.value ?: "", ) }, actions = {