feat: implement Android database layer with Room
- Add SubscriptionEntity, FeedItemEntity, SearchHistoryEntity - Create SubscriptionDao, FeedItemDao, SearchHistoryDao with CRUD operations - Implement RssDatabase with FTS5 virtual table for full-text search - Add type converters for Date, String lists, and FeedItem lists - Implement cascade delete for feed items when subscription is removed - Add comprehensive unit tests for all DAOs - Add database integration tests for entity round-trips and FTS - Configure Room testing dependencies
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
package com.rssuper.database
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.rssuper.converters.DateConverter
|
||||
import com.rssuper.converters.FeedItemListConverter
|
||||
import com.rssuper.converters.StringListConverter
|
||||
import com.rssuper.database.daos.FeedItemDao
|
||||
import com.rssuper.database.daos.SearchHistoryDao
|
||||
import com.rssuper.database.daos.SubscriptionDao
|
||||
import com.rssuper.database.entities.FeedItemEntity
|
||||
import com.rssuper.database.entities.SearchHistoryEntity
|
||||
import com.rssuper.database.entities.SubscriptionEntity
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Date
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
SubscriptionEntity::class,
|
||||
FeedItemEntity::class,
|
||||
SearchHistoryEntity::class
|
||||
],
|
||||
version = 1,
|
||||
exportSchema = true
|
||||
)
|
||||
@TypeConverters(DateConverter::class, StringListConverter::class, FeedItemListConverter::class)
|
||||
abstract class RssDatabase : RoomDatabase() {
|
||||
|
||||
abstract fun subscriptionDao(): SubscriptionDao
|
||||
abstract fun feedItemDao(): FeedItemDao
|
||||
abstract fun searchHistoryDao(): SearchHistoryDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var INSTANCE: RssDatabase? = null
|
||||
|
||||
fun getDatabase(context: Context): RssDatabase {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
val instance = Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
RssDatabase::class.java,
|
||||
"rss_database"
|
||||
)
|
||||
.addCallback(DatabaseCallback())
|
||||
.build()
|
||||
INSTANCE = instance
|
||||
instance
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class DatabaseCallback : RoomDatabase.Callback() {
|
||||
override fun onCreate(db: SupportSQLiteDatabase) {
|
||||
super.onCreate(db)
|
||||
INSTANCE?.let { database ->
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
createFTSVirtualTable(db)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOpen(db: SupportSQLiteDatabase) {
|
||||
super.onOpen(db)
|
||||
createFTSVirtualTable(db)
|
||||
}
|
||||
|
||||
private fun createFTSVirtualTable(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("""
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS feed_items_fts USING fts5(
|
||||
title,
|
||||
description,
|
||||
content,
|
||||
author,
|
||||
content='feed_items',
|
||||
contentless_delete=true
|
||||
)
|
||||
""".trimIndent())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.rssuper.database.daos
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Update
|
||||
import com.rssuper.database.entities.FeedItemEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import java.util.Date
|
||||
|
||||
@Dao
|
||||
interface FeedItemDao {
|
||||
@Query("SELECT * FROM feed_items WHERE subscriptionId = :subscriptionId ORDER BY published DESC")
|
||||
fun getItemsBySubscription(subscriptionId: String): Flow<List<FeedItemEntity>>
|
||||
|
||||
@Query("SELECT * FROM feed_items WHERE id = :id")
|
||||
suspend fun getItemById(id: String): FeedItemEntity?
|
||||
|
||||
@Query("SELECT * FROM feed_items WHERE subscriptionId IN (:subscriptionIds) ORDER BY published DESC")
|
||||
fun getItemsBySubscriptions(subscriptionIds: List<String>): Flow<List<FeedItemEntity>>
|
||||
|
||||
@Query("SELECT * FROM feed_items WHERE isRead = 0 ORDER BY published DESC")
|
||||
fun getUnreadItems(): Flow<List<FeedItemEntity>>
|
||||
|
||||
@Query("SELECT * FROM feed_items WHERE isStarred = 1 ORDER BY published DESC")
|
||||
fun getStarredItems(): Flow<List<FeedItemEntity>>
|
||||
|
||||
@Query("SELECT * FROM feed_items WHERE published > :date ORDER BY published DESC")
|
||||
fun getItemsAfterDate(date: Date): Flow<List<FeedItemEntity>>
|
||||
|
||||
@Query("SELECT * FROM feed_items WHERE subscriptionId = :subscriptionId AND published > :date ORDER BY published DESC")
|
||||
fun getSubscriptionItemsAfterDate(subscriptionId: String, date: Date): Flow<List<FeedItemEntity>>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM feed_items WHERE subscriptionId = :subscriptionId AND isRead = 0")
|
||||
fun getUnreadCount(subscriptionId: String): Flow<Int>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM feed_items WHERE isRead = 0")
|
||||
fun getTotalUnreadCount(): Flow<Int>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertItem(item: FeedItemEntity): Long
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertItems(items: List<FeedItemEntity>): List<Long>
|
||||
|
||||
@Update
|
||||
suspend fun updateItem(item: FeedItemEntity): Int
|
||||
|
||||
@Delete
|
||||
suspend fun deleteItem(item: FeedItemEntity): Int
|
||||
|
||||
@Query("DELETE FROM feed_items WHERE id = :id")
|
||||
suspend fun deleteItemById(id: String): Int
|
||||
|
||||
@Query("DELETE FROM feed_items WHERE subscriptionId = :subscriptionId")
|
||||
suspend fun deleteItemsBySubscription(subscriptionId: String): Int
|
||||
|
||||
@Query("UPDATE feed_items SET isRead = 1 WHERE id = :id")
|
||||
suspend fun markAsRead(id: String): Int
|
||||
|
||||
@Query("UPDATE feed_items SET isRead = 0 WHERE id = :id")
|
||||
suspend fun markAsUnread(id: String): Int
|
||||
|
||||
@Query("UPDATE feed_items SET isStarred = 1 WHERE id = :id")
|
||||
suspend fun markAsStarred(id: String): Int
|
||||
|
||||
@Query("UPDATE feed_items SET isStarred = 0 WHERE id = :id")
|
||||
suspend fun markAsUnstarred(id: String): Int
|
||||
|
||||
@Query("UPDATE feed_items SET isRead = 1 WHERE subscriptionId = :subscriptionId")
|
||||
suspend fun markAllAsRead(subscriptionId: String): Int
|
||||
|
||||
@Query("SELECT * FROM feed_items WHERE subscriptionId = :subscriptionId LIMIT :limit OFFSET :offset")
|
||||
suspend fun getItemsPaginated(subscriptionId: String, limit: Int, offset: Int): List<FeedItemEntity>
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.rssuper.database.daos
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Update
|
||||
import com.rssuper.database.entities.SearchHistoryEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface SearchHistoryDao {
|
||||
@Query("SELECT * FROM search_history ORDER BY timestamp DESC")
|
||||
fun getAllSearchHistory(): Flow<List<SearchHistoryEntity>>
|
||||
|
||||
@Query("SELECT * FROM search_history WHERE id = :id")
|
||||
suspend fun getSearchHistoryById(id: String): SearchHistoryEntity?
|
||||
|
||||
@Query("SELECT * FROM search_history WHERE query LIKE '%' || :query || '%' ORDER BY timestamp DESC")
|
||||
fun searchHistory(query: String): Flow<List<SearchHistoryEntity>>
|
||||
|
||||
@Query("SELECT * FROM search_history ORDER BY timestamp DESC LIMIT :limit")
|
||||
fun getRecentSearches(limit: Int): Flow<List<SearchHistoryEntity>>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM search_history")
|
||||
fun getSearchHistoryCount(): Flow<Int>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun insertSearchHistory(search: SearchHistoryEntity): Long
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun insertSearchHistories(searches: List<SearchHistoryEntity>): List<Long>
|
||||
|
||||
@Update
|
||||
suspend fun updateSearchHistory(search: SearchHistoryEntity): Int
|
||||
|
||||
@Delete
|
||||
suspend fun deleteSearchHistory(search: SearchHistoryEntity): Int
|
||||
|
||||
@Query("DELETE FROM search_history WHERE id = :id")
|
||||
suspend fun deleteSearchHistoryById(id: String): Int
|
||||
|
||||
@Query("DELETE FROM search_history")
|
||||
suspend fun deleteAllSearchHistory(): Int
|
||||
|
||||
@Query("DELETE FROM search_history WHERE timestamp < :timestamp")
|
||||
suspend fun deleteSearchHistoryOlderThan(timestamp: Long): Int
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.rssuper.database.daos
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Update
|
||||
import com.rssuper.database.entities.SubscriptionEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import java.util.Date
|
||||
|
||||
@Dao
|
||||
interface SubscriptionDao {
|
||||
@Query("SELECT * FROM subscriptions ORDER BY title ASC")
|
||||
fun getAllSubscriptions(): Flow<List<SubscriptionEntity>>
|
||||
|
||||
@Query("SELECT * FROM subscriptions WHERE id = :id")
|
||||
suspend fun getSubscriptionById(id: String): SubscriptionEntity?
|
||||
|
||||
@Query("SELECT * FROM subscriptions WHERE url = :url")
|
||||
suspend fun getSubscriptionByUrl(url: String): SubscriptionEntity?
|
||||
|
||||
@Query("SELECT * FROM subscriptions WHERE enabled = 1 ORDER BY title ASC")
|
||||
fun getEnabledSubscriptions(): Flow<List<SubscriptionEntity>>
|
||||
|
||||
@Query("SELECT * FROM subscriptions WHERE category = :category ORDER BY title ASC")
|
||||
fun getSubscriptionsByCategory(category: String): Flow<List<SubscriptionEntity>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertSubscription(subscription: SubscriptionEntity): Long
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertSubscriptions(subscriptions: List<SubscriptionEntity>): List<Long>
|
||||
|
||||
@Update
|
||||
suspend fun updateSubscription(subscription: SubscriptionEntity): Int
|
||||
|
||||
@Delete
|
||||
suspend fun deleteSubscription(subscription: SubscriptionEntity): Int
|
||||
|
||||
@Query("DELETE FROM subscriptions WHERE id = :id")
|
||||
suspend fun deleteSubscriptionById(id: String): Int
|
||||
|
||||
@Query("SELECT COUNT(*) FROM subscriptions")
|
||||
fun getSubscriptionCount(): Flow<Int>
|
||||
|
||||
@Query("UPDATE subscriptions SET error = :error WHERE id = :id")
|
||||
suspend fun updateError(id: String, error: String?)
|
||||
|
||||
@Query("UPDATE subscriptions SET lastFetchedAt = :lastFetchedAt, error = NULL WHERE id = :id")
|
||||
suspend fun updateLastFetchedAt(id: String, lastFetchedAt: Date)
|
||||
|
||||
@Query("UPDATE subscriptions SET nextFetchAt = :nextFetchAt WHERE id = :id")
|
||||
suspend fun updateNextFetchAt(id: String, nextFetchAt: Date)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.rssuper.database.entities
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import java.util.Date
|
||||
|
||||
@Entity(
|
||||
tableName = "feed_items",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = SubscriptionEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["subscriptionId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)
|
||||
],
|
||||
indices = [
|
||||
Index(value = ["subscriptionId"]),
|
||||
Index(value = ["published"])
|
||||
]
|
||||
)
|
||||
data class FeedItemEntity(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
|
||||
val subscriptionId: String,
|
||||
|
||||
val title: String,
|
||||
|
||||
val link: String? = null,
|
||||
|
||||
val description: String? = null,
|
||||
|
||||
val content: String? = null,
|
||||
|
||||
val author: String? = null,
|
||||
|
||||
val published: Date? = null,
|
||||
|
||||
val updated: Date? = null,
|
||||
|
||||
val categories: String? = null,
|
||||
|
||||
val enclosureUrl: String? = null,
|
||||
|
||||
val enclosureType: String? = null,
|
||||
|
||||
val enclosureLength: Long? = null,
|
||||
|
||||
val guid: String? = null,
|
||||
|
||||
val isRead: Boolean = false,
|
||||
|
||||
val isStarred: Boolean = false
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.rssuper.database.entities
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import java.util.Date
|
||||
|
||||
@Entity(
|
||||
tableName = "search_history",
|
||||
indices = [Index(value = ["timestamp"])]
|
||||
)
|
||||
data class SearchHistoryEntity(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
|
||||
val query: String,
|
||||
|
||||
val timestamp: Date
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.rssuper.database.entities
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import com.rssuper.models.HttpAuth
|
||||
import java.util.Date
|
||||
|
||||
@Entity(
|
||||
tableName = "subscriptions",
|
||||
indices = [Index(value = ["url"], unique = true)]
|
||||
)
|
||||
data class SubscriptionEntity(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
|
||||
val url: String,
|
||||
|
||||
val title: String,
|
||||
|
||||
val category: String? = null,
|
||||
|
||||
val enabled: Boolean = true,
|
||||
|
||||
val fetchInterval: Long = 3600000,
|
||||
|
||||
val createdAt: Date,
|
||||
|
||||
val updatedAt: Date,
|
||||
|
||||
val lastFetchedAt: Date? = null,
|
||||
|
||||
val nextFetchAt: Date? = null,
|
||||
|
||||
val error: String? = null,
|
||||
|
||||
val httpAuthUsername: String? = null,
|
||||
|
||||
val httpAuthPassword: String? = null
|
||||
) {
|
||||
fun toHttpAuth(): HttpAuth? {
|
||||
return if (httpAuthUsername != null && httpAuthPassword != null) {
|
||||
HttpAuth(httpAuthUsername, httpAuthPassword)
|
||||
} else null
|
||||
}
|
||||
|
||||
fun fromHttpAuth(auth: HttpAuth?): SubscriptionEntity {
|
||||
return copy(
|
||||
httpAuthUsername = auth?.username,
|
||||
httpAuthPassword = auth?.password
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user