Properly handle update / insert and delete in the adventure database.

This commit is contained in:
Andres Gomez, Thomas (ITDV RL) 2024-06-23 08:42:09 +02:00
parent 7400799efd
commit 2b03ffd1ac
7 changed files with 230 additions and 211 deletions

View file

@ -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')"
]
}
}

View file

@ -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<List<AdventureLineDbo>>
@Query("SELECT * from ${AdventureBookDbo.TABLE}")
fun findBooks(): List<AdventureBookDbo>
fun fetchAdventureBooks(): List<AdventureBookDbo>
@Query("SELECT * from ${AdventureStoryDbo.TABLE}")
fun findStories(): List<AdventureStoryDbo>
fun fetchAdventureStories(): List<AdventureStoryDbo>
@Query("SELECT * from ${AdventureStoryDbo.TABLE} where documentId = :documentId")
fun findStories(documentId: String): List<AdventureStoryDbo>
fun fetchAdventureStories(documentId: String): List<AdventureStoryDbo>
@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<AdventureLineDbo>
@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<AdventureBookDbo>,
booksToInsert: List<AdventureBookDbo>,
booksToUpdate: List<AdventureBookDbo>,
booksToRemove: List<AdventureBookDbo.BookId>,
stories: List<AdventureStoryDbo>,
storiesToInsert: List<AdventureStoryDbo>,
storiesToUpdate: List<AdventureStoryDbo>,
storiesToRemove: List<AdventureStoryDbo.StoryId>,
lines: List<AdventureLineDbo>,
linesToInsert: List<AdventureLineDbo>,
linesToUpdate: List<AdventureLineDbo>,
linesToRemove: List<AdventureLineDbo.LineId>,
) {
// 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) }
}
}

View file

@ -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,
)
],
)

View file

@ -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<List<Adventure>> {
fun storyFlow(bookTitle: String): Flow<List<Adventure>> {
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<Adventure?> {
fun adventureFlow(documentId: String, adventureTitle: String): Flow<Adventure?> {
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<AdventureBookDbo> = 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<AdventureStoryDbo> = 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<AdventureLineDbo> = 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<AdventureStoryDbo> = 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<AdventureLineDbo> = 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<AdventureBookDbo>()
val toUpdate = mutableListOf<AdventureBookDbo>()
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<AdventureStoryDbo>()
val toUpdate = mutableListOf<AdventureStoryDbo>()
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<AdventureBook> {
return googleRepository.fetch { sheet ->
val request = sheet.get(Adventures.ID, Adventures.ADVENTURES)
adventureBookParser.parse(sheet = request.execute())
}
}
private data class DatabaseUpdateScope(
val booksToInsert: MutableList<AdventureBookDbo> = mutableListOf(),
val booksToUpdate: MutableList<AdventureBookDbo> = mutableListOf(),
val booksToRemove: MutableList<AdventureBookDbo.BookId> = mutableListOf(),
val storiesToInsert: MutableList<AdventureStoryDbo> = mutableListOf(),
val storiesToUpdate: MutableList<AdventureStoryDbo> = mutableListOf(),
val storiesToRemove: MutableList<AdventureStoryDbo.StoryId> = mutableListOf(),
val linesToInsert: MutableList<AdventureLineDbo> = mutableListOf(),
val linesToUpdate: MutableList<AdventureLineDbo> = mutableListOf(),
val linesToRemove: MutableList<AdventureLineDbo.LineId> = mutableListOf(),
)
@Throws(IncompatibleSheetStructure::class, Exception::class)
private suspend fun fetchAdventureStory(documentId: String): List<AdventureStory> {
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<AdventureLine> {
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<AdventureBookDbo> {
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<AdventureStoryDbo> {
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<AdventureLineDbo> {
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()
}
}
}

View file

@ -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,

View file

@ -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()

View file

@ -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 = {