Refactor: Enhance AppDao query capabilities

This commit refactors the `AppDao` to provide more flexible and robust querying options for application data.

Key changes:

- **Renamed `_rawQueryAppEntities` to `_rawStreamAppEntities`**: This clarifies the function's purpose of returning a Flow of entities.
- **Added `_rawQueryAppEntities`**: A new suspend function that directly returns a List of entities, for non-streaming use cases.
- **Introduced `query` function**: A new public suspend function that mirrors the functionality of `stream` but returns a `List<AppEntity>` instead of a `Flow`.
- **Enhanced `searchQuery` private function**:
    - Now accepts lists for `categoriesToInclude`, `categoriesToExclude`, `antiFeaturesToInclude`, and `antiFeaturesToExclude` to allow filtering by multiple criteria.
    - Uses `DISTINCT` in the SQL query to avoid duplicate app entries.
    - Corrected join condition for `category_app_relation` from `app.id = category_app_relation.appId` to `app.id = category_app_relation.id`.
    - Corrected table name for anti-features from `anti_feature_app_relation` to `anti_features_app_relation`.
    - Improved SQL query construction for category and anti-feature filtering using `IN` and `NOT IN` clauses.
    - Ensured `ORDER BY` clause is always present, even if `searchQuery` is null.
    - Prefixed table names in `ORDER BY` clause (e.g., `app.lastUpdated`) for clarity and to avoid ambiguity.
- **Updated `stream` function**: Now utilizes the refactored `searchQuery` function and passes through all new filtering parameters.
- **Updated database schema**:
    - Changed `onDelete` action for the foreign key in the `authentication` table to `CASCADE`.
- **Updated Room tests**:
    - Simplified setup by removing legacy database initialization.
    - Added tests for new sorting and category filtering functionalities in `AppDao`.
This commit is contained in:
LooKeR 2025-06-01 01:47:59 +05:30
parent c943c2a149
commit ae2bdaea19
No known key found for this signature in database
GPG Key ID: 6B59369FDB608FB9
3 changed files with 155 additions and 142 deletions

View File

@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "099d07ab258fe12cb0a660545bd36e63",
"identityHash": "e64dac21017b34894f6d9d7890184178",
"entities": [
{
"tableName": "anti_feature",
@ -142,7 +142,7 @@
},
{
"tableName": "authentication",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`password` TEXT NOT NULL, `username` TEXT NOT NULL, `initializationVector` TEXT NOT NULL, `repoId` INTEGER NOT NULL, PRIMARY KEY(`repoId`), FOREIGN KEY(`repoId`) REFERENCES `repository`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`password` TEXT NOT NULL, `username` TEXT NOT NULL, `initializationVector` TEXT NOT NULL, `repoId` INTEGER NOT NULL, PRIMARY KEY(`repoId`), FOREIGN KEY(`repoId`) REFERENCES `repository`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "password",
@ -179,7 +179,7 @@
"foreignKeys": [
{
"table": "repository",
"onDelete": "NO ACTION",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"repoId"
@ -1078,7 +1078,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, '099d07ab258fe12cb0a660545bd36e63')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e64dac21017b34894f6d9d7890184178')"
]
}
}

View File

@ -3,32 +3,25 @@ package com.looker.droidify
import android.content.Context
import com.looker.droidify.data.local.dao.AppDao
import com.looker.droidify.data.local.dao.IndexDao
import com.looker.droidify.database.Database
import com.looker.droidify.datastore.model.SortOrder
import com.looker.droidify.domain.model.Fingerprint
import com.looker.droidify.domain.model.Repo
import com.looker.droidify.domain.model.VersionInfo
import com.looker.droidify.index.RepositoryUpdater
import com.looker.droidify.index.RepositoryUpdater.IndexType
import com.looker.droidify.model.Repository
import com.looker.droidify.sync.FakeDownloader
import com.looker.droidify.sync.common.JsonParser
import com.looker.droidify.sync.common.assets
import com.looker.droidify.sync.common.benchmark
import com.looker.droidify.sync.common.downloadIndex
import com.looker.droidify.sync.v2.model.IndexV2
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import java.io.File
import javax.inject.Inject
import kotlin.system.measureTimeMillis
import kotlin.test.Test
import kotlin.test.assertTrue
@HiltAndroidTest
class RoomTesting {
@ -53,98 +46,60 @@ class RoomTesting {
@Before
fun before() = runTest {
hiltRule.inject()
setupLegacy()
}
@Test
fun roomBenchmark() = runTest {
launch {
val izzy = izzyLegacy.toRepo(1)
val insert = benchmark(1) {
val v2File = FakeDownloader
.downloadIndex(context, izzy, "izzy-v2", "index-v2.json")
measureTimeMillis {
val index = JsonParser.decodeFromString<IndexV2>(
v2File.readBytes().decodeToString(),
)
val izzyFile = FakeDownloader.downloadIndex(context, izzy, "i2", "index-v2.json")
val izzyIndex =
JsonParser.decodeFromString<IndexV2>(izzyFile.readBytes().decodeToString())
indexDao.insertIndex(
fingerprint = izzy.fingerprint!!,
index = index,
index = izzyIndex,
expectedRepoId = izzy.id,
)
}
}
val fdroid = fdroidLegacy.toRepo(2)
val insertFDroid = benchmark(1) {
val v2File = FakeDownloader
.downloadIndex(context, fdroid, "fdroid-v2", "fdroid-index-v2.json")
measureTimeMillis {
val index = JsonParser.decodeFromString<IndexV2>(
v2File.readBytes().decodeToString(),
)
indexDao.insertIndex(
fingerprint = fdroid.fingerprint!!,
index = index,
expectedRepoId = fdroid.id,
)
}
}
val query = appDao.queryAppEntity("com.looker.droidify")
println(query.first().joinToString("\n"))
println(insert)
println(insertFDroid)
// launch {
// val fdroid = fdroidLegacy.toRepo(2)
// val fdroidFile =
// FakeDownloader.downloadIndex(context, fdroid, "f2", "fdroid-index-v2.json")
// val fdroidIndex =
// JsonParser.decodeFromString<IndexV2>(fdroidFile.readBytes().decodeToString())
// indexDao.insertIndex(
// fingerprint = fdroid.fingerprint!!,
// index = fdroidIndex,
// expectedRepoId = fdroid.id,
// )
// }
}
@Test
fun legacyBenchmark() {
val insert = benchmark(1) {
val createFile = File.createTempFile("index", "entry")
val mergerFile = File.createTempFile("index", "merger")
val jarStream = assets("izzy_index_v1.jar")
jarStream.copyTo(createFile.outputStream())
measureTimeMillis {
RepositoryUpdater.processFile(
context = context,
repository = izzyLegacy,
indexType = IndexType.INDEX_V1,
unstable = false,
file = createFile,
mergerFile = mergerFile,
lastModified = "",
entityTag = "",
callback = { _, _, _ -> },
)
createFile.delete()
mergerFile.delete()
}
}
val insertFDroid = benchmark(1) {
val createFile = File.createTempFile("index", "entry")
val mergerFile = File.createTempFile("index", "merger")
val jarStream = assets("fdroid_index_v1.jar")
jarStream.copyTo(createFile.outputStream())
measureTimeMillis {
RepositoryUpdater.processFile(
context = context,
repository = fdroidLegacy,
indexType = IndexType.INDEX_V1,
unstable = false,
file = createFile,
mergerFile = mergerFile,
lastModified = "",
entityTag = "",
callback = { _, _, _ -> },
)
createFile.delete()
mergerFile.delete()
}
}
println(insert)
println(insertFDroid)
fun sortOrderTest() = runTest {
val lastUpdatedQuery = appDao.query(sortOrder = SortOrder.UPDATED)
var previousUpdated = Long.MAX_VALUE
lastUpdatedQuery.forEach {
println("Previous: $previousUpdated, Current: ${it.lastUpdated}")
assertTrue(it.lastUpdated <= previousUpdated)
previousUpdated = it.lastUpdated
}
private fun setupLegacy() {
Database.init(context)
RepositoryUpdater.init(CoroutineScope(Dispatchers.Default), FakeDownloader)
val addedQuery = appDao.query(sortOrder = SortOrder.ADDED)
var previousAdded = Long.MAX_VALUE
addedQuery.forEach {
println("Previous: $previousAdded, Current: ${it.added}")
assertTrue(it.added <= previousAdded)
previousAdded = it.added
}
}
@Test
fun categoryTest() = runTest {
val categoryQuery = appDao.query(
sortOrder = SortOrder.UPDATED,
categoriesToInclude = listOf("Games", "Food"),
)
val nonCategoryQuery = appDao.query(
sortOrder = SortOrder.UPDATED,
categoriesToExclude = listOf("Games", "Food"),
)
}
}

View File

@ -18,67 +18,129 @@ import kotlinx.coroutines.flow.Flow
@Dao
interface AppDao {
@RawQuery(observedEntities = [
@RawQuery(
observedEntities = [
AppEntity::class,
VersionEntity::class,
CategoryAppRelation::class,
AntiFeatureAppRelation::class,
])
fun _rawQueryAppEntities(query: SimpleSQLiteQuery): Flow<List<AppEntity>>
],
)
fun _rawStreamAppEntities(query: SimpleSQLiteQuery): Flow<List<AppEntity>>
@RawQuery
suspend fun _rawQueryAppEntities(query: SimpleSQLiteQuery): List<AppEntity>
/**
* @param category: get by default name and use the boolean to ignore the app category
* @param antiFeature: get by tag and use the boolean to ignore the anti feature
*
* Add `canUpdate` and `isInstalled` is the next layer
* */
fun stream(
sortOrder: SortOrder,
searchQuery: String? = null,
repoId: Int? = null,
categoriesToInclude: List<DefaultName>? = null,
categoriesToExclude: List<DefaultName>? = null,
antiFeaturesToInclude: List<Tag>? = null,
antiFeaturesToExclude: List<Tag>? = null,
): Flow<List<AppEntity>> = _rawStreamAppEntities(
searchQuery(
sortOrder = sortOrder,
searchQuery = searchQuery,
repoId = repoId,
categoriesToInclude = categoriesToInclude,
categoriesToExclude = categoriesToExclude,
antiFeaturesToInclude = antiFeaturesToInclude,
antiFeaturesToExclude = antiFeaturesToExclude,
),
)
suspend fun query(
sortOrder: SortOrder,
searchQuery: String? = null,
repoId: Int? = null,
categoriesToInclude: List<DefaultName>? = null,
categoriesToExclude: List<DefaultName>? = null,
antiFeaturesToInclude: List<Tag>? = null,
antiFeaturesToExclude: List<Tag>? = null,
): List<AppEntity> = _rawQueryAppEntities(
searchQuery(
sortOrder = sortOrder,
searchQuery = searchQuery,
repoId = repoId,
categoriesToInclude = categoriesToInclude,
categoriesToExclude = categoriesToExclude,
antiFeaturesToInclude = antiFeaturesToInclude,
antiFeaturesToExclude = antiFeaturesToExclude,
),
)
private fun searchQuery(
sortOrder: SortOrder,
searchQuery: String?,
repoId: Int?,
category: Pair<DefaultName, Boolean>?,
antiFeature: Pair<Tag, Boolean>?,
): Flow<List<AppEntity>> {
categoriesToInclude: List<DefaultName>?,
categoriesToExclude: List<DefaultName>?,
antiFeaturesToInclude: List<Tag>?,
antiFeaturesToExclude: List<Tag>?,
): SimpleSQLiteQuery {
val args = arrayListOf<Any?>()
val query = buildString(1024) {
append("SELECT app.* FROM app")
append("SELECT DISTINCT app.* FROM app")
append(" LEFT JOIN version ON app.id = version.appId")
append(" LEFT JOIN category_app_relation ON app.id = category_app_relation.appId")
append(" LEFT JOIN anti_feature_app_relation ON app.id = anti_feature_app_relation.appId")
append(" LEFT JOIN category_app_relation ON app.id = category_app_relation.id")
append(" LEFT JOIN anti_features_app_relation ON app.id = anti_features_app_relation.appId")
append(" WHERE 1")
if (repoId != null) {
append(" AND app.repoId = ?")
args.add(repoId)
}
if (category != null) {
val (name, hide) = category
if (!hide) {
append(" AND category_app_relation.defaultName = ?")
} else {
append(" AND category_app_relation.defaultName != ?")
}
args.add(name)
if (categoriesToInclude != null) {
append(" AND category_app_relation.defaultName IN (")
append(categoriesToInclude.joinToString(", ") { "?" })
append(")")
args.addAll(categoriesToInclude)
}
if (antiFeature != null) {
val (tag, hide) = antiFeature
if (!hide) {
append(" AND anti_feature_app_relation.tag = ?")
} else {
append(" AND anti_feature_app_relation.tag != ?")
if (categoriesToExclude != null) {
append(" AND category_app_relation.defaultName NOT IN (")
append(categoriesToExclude.joinToString(", ") { "?" })
append(")")
args.addAll(categoriesToExclude)
}
args.add(tag)
if (antiFeaturesToInclude != null) {
append(" AND anti_features_app_relation.tag IN (")
append(antiFeaturesToInclude.joinToString(", ") { "?" })
append(")")
args.addAll(antiFeaturesToInclude)
}
if (antiFeaturesToExclude != null) {
append(" AND anti_features_app_relation.tag NOT IN (")
append(antiFeaturesToExclude.joinToString(", ") { "?" })
append(")")
args.addAll(antiFeaturesToExclude)
}
if (searchQuery != null) {
val searchPattern = "%${searchQuery}%"
append(" AND (app.name LIKE ? OR app.summary LIKE ? OR app.packageName LIKE ? OR app.description LIKE ?)")
append(
"""
AND (
app.name LIKE ?
OR app.summary LIKE ?
OR app.packageName LIKE ?
OR app.description LIKE ?
)""",
)
args.addAll(listOf(searchPattern, searchPattern, searchPattern, searchPattern))
}
append(" ORDER BY ")
// Weighting: name > summary > packageName > description
if (searchQuery != null) {
val searchPattern = "%${searchQuery}%"
append("(CASE WHEN app.name LIKE ? THEN 4 ELSE 0 END) + ")
append("(CASE WHEN app.summary LIKE ? THEN 3 ELSE 0 END) + ")
append("(CASE WHEN app.packageName LIKE ? THEN 2 ELSE 0 END) + ")
@ -86,12 +148,8 @@ interface AppDao {
args.addAll(listOf(searchPattern, searchPattern, searchPattern, searchPattern))
}
if (searchQuery == null) {
append(" ORDER BY ")
}
when (sortOrder) {
SortOrder.UPDATED -> append("app,lastUpdated DESC, ")
SortOrder.UPDATED -> append("app.lastUpdated DESC, ")
SortOrder.ADDED -> append("app.added DESC, ")
SortOrder.SIZE -> append("version.apk_size DESC, ")
SortOrder.NAME -> Unit
@ -99,7 +157,7 @@ interface AppDao {
append("app.name COLLATE LOCALIZED ASC")
}
return _rawQueryAppEntities(SimpleSQLiteQuery(query, args.toTypedArray()))
return SimpleSQLiteQuery(query, args.toTypedArray())
}
@Query(