Refactor: Extract database helper logic into separate classes

This commit refactors the database management logic by:

- Introducing a `Table` interface to define common table operations.
- Creating a `DatabaseHelper` class that extends `SQLiteOpenHelper` to encapsulate database creation, upgrades, and table/index management.
- Moving the `Schema` object into `Database` to maintain its accessibility.
- Making the `query` extension function on `SQLiteDatabase` public.

This improves code organization and maintainability by separating concerns related to database structure and helper functionalities.
This commit is contained in:
LooKeR 2025-06-23 19:49:28 +05:30
parent 402482e4c1
commit d6163a518d
No known key found for this signature in database
GPG Key ID: 6B59369FDB608FB9
3 changed files with 216 additions and 162 deletions

View File

@ -9,7 +9,8 @@ import android.os.CancellationSignal
import androidx.core.database.sqlite.transaction import androidx.core.database.sqlite.transaction
import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.core.JsonParser
import com.looker.droidify.BuildConfig import com.looker.droidify.database.table.DatabaseHelper
import com.looker.droidify.database.table.Table
import com.looker.droidify.datastore.model.SortOrder import com.looker.droidify.datastore.model.SortOrder
import com.looker.droidify.model.InstalledItem import com.looker.droidify.model.InstalledItem
import com.looker.droidify.model.Product import com.looker.droidify.model.Product
@ -20,7 +21,6 @@ import com.looker.droidify.utility.common.extension.asSequence
import com.looker.droidify.utility.common.extension.firstOrNull import com.looker.droidify.utility.common.extension.firstOrNull
import com.looker.droidify.utility.common.extension.parseDictionary import com.looker.droidify.utility.common.extension.parseDictionary
import com.looker.droidify.utility.common.extension.writeDictionary import com.looker.droidify.utility.common.extension.writeDictionary
import com.looker.droidify.utility.common.log
import com.looker.droidify.utility.serialization.product import com.looker.droidify.utility.serialization.product
import com.looker.droidify.utility.serialization.productItem import com.looker.droidify.utility.serialization.productItem
import com.looker.droidify.utility.serialization.repository import com.looker.droidify.utility.serialization.repository
@ -44,52 +44,15 @@ import kotlin.collections.set
object Database { object Database {
fun init(context: Context): Boolean { fun init(context: Context): Boolean {
val helper = Helper(context) val helper = DatabaseHelper(context)
db = helper.writableDatabase db = helper.writableDatabase
if (helper.created) {
for (repository in Repository.defaultRepositories.sortedBy { it.name }) {
RepositoryAdapter.put(repository)
}
}
RepositoryAdapter.removeDuplicates() RepositoryAdapter.removeDuplicates()
return helper.created || helper.updated return helper.created || helper.updated
} }
private lateinit var db: SQLiteDatabase private lateinit var db: SQLiteDatabase
private interface Table { object Schema {
val memory: Boolean
val innerName: String
val createTable: String
val createIndex: String?
get() = null
val databasePrefix: String
get() = if (memory) "memory." else ""
val name: String
get() = "$databasePrefix$innerName"
fun formatCreateTable(name: String): String {
return buildString(128) {
append("CREATE TABLE ")
append(name)
append(" (")
trimAndJoin(createTable)
append(")")
}
}
val createIndexPairFormatted: Pair<String, String>?
get() = createIndex?.let {
Pair(
"CREATE INDEX ${innerName}_index ON $innerName ($it)",
"CREATE INDEX ${name}_index ON $innerName ($it)",
)
}
}
private object Schema {
object Repository : Table { object Repository : Table {
const val ROW_ID = "_id" const val ROW_ID = "_id"
const val ROW_ENABLED = "enabled" const val ROW_ENABLED = "enabled"
@ -190,126 +153,6 @@ object Database {
} }
} }
private class Helper(context: Context) : SQLiteOpenHelper(context, "droidify", null, 5) {
var created = false
private set
var updated = false
private set
override fun onCreate(db: SQLiteDatabase) = Unit
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) =
onVersionChange(db)
override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) =
onVersionChange(db)
private fun onVersionChange(db: SQLiteDatabase) {
handleTables(db, true, Schema.Product, Schema.Category)
addRepos(db, Repository.newlyAdded)
this.updated = true
}
override fun onOpen(db: SQLiteDatabase) {
val create = handleTables(db, false, Schema.Repository)
val updated = handleTables(db, create, Schema.Product, Schema.Category)
db.execSQL("ATTACH DATABASE ':memory:' AS memory")
handleTables(db, false, Schema.Installed, Schema.Lock)
handleIndexes(
db,
Schema.Repository,
Schema.Product,
Schema.Category,
Schema.Installed,
Schema.Lock,
)
dropOldTables(db, Schema.Repository, Schema.Product, Schema.Category)
this.created = this.created || create
this.updated = this.updated || create || updated
}
}
private fun handleTables(db: SQLiteDatabase, recreate: Boolean, vararg tables: Table): Boolean {
val shouldRecreate = recreate || tables.any { table ->
val sql = db.query(
"${table.databasePrefix}sqlite_master",
columns = arrayOf("sql"),
selection = Pair("type = ? AND name = ?", arrayOf("table", table.innerName)),
).use { it.firstOrNull()?.getString(0) }.orEmpty()
table.formatCreateTable(table.innerName) != sql
}
return shouldRecreate && run {
val shouldVacuum = tables.map {
db.execSQL("DROP TABLE IF EXISTS ${it.name}")
db.execSQL(it.formatCreateTable(it.name))
!it.memory
}
if (shouldVacuum.any { it } && !db.inTransaction()) {
db.execSQL("VACUUM")
}
true
}
}
private fun addRepos(db: SQLiteDatabase, repos: List<Repository>) {
if (BuildConfig.DEBUG) {
log("Add Repos: $repos", "RepositoryAdapter")
}
if (repos.isEmpty()) return
db.transaction {
repos.forEach {
RepositoryAdapter.put(it, database = this)
}
}
}
private fun handleIndexes(db: SQLiteDatabase, vararg tables: Table) {
val shouldVacuum = tables.map { table ->
val sqls = db.query(
"${table.databasePrefix}sqlite_master",
columns = arrayOf("name", "sql"),
selection = Pair("type = ? AND tbl_name = ?", arrayOf("index", table.innerName)),
)
.use { cursor ->
cursor.asSequence()
.mapNotNull { it.getString(1)?.let { sql -> Pair(it.getString(0), sql) } }
.toList()
}
.filter { !it.first.startsWith("sqlite_") }
val createIndexes = table.createIndexPairFormatted?.let { listOf(it) }.orEmpty()
createIndexes.map { it.first } != sqls.map { it.second } && run {
for (name in sqls.map { it.first }) {
db.execSQL("DROP INDEX IF EXISTS $name")
}
for (createIndexPair in createIndexes) {
db.execSQL(createIndexPair.second)
}
!table.memory
}
}
if (shouldVacuum.any { it } && !db.inTransaction()) {
db.execSQL("VACUUM")
}
}
private fun dropOldTables(db: SQLiteDatabase, vararg neededTables: Table) {
val tables = db.query(
"sqlite_master",
columns = arrayOf("name"),
selection = Pair("type = ?", arrayOf("table")),
)
.use { cursor -> cursor.asSequence().mapNotNull { it.getString(0) }.toList() }
.filter { !it.startsWith("sqlite_") && !it.startsWith("android_") }
.toSet() - neededTables.mapNotNull { if (it.memory) null else it.name }.toSet()
if (tables.isNotEmpty()) {
for (table in tables) {
db.execSQL("DROP TABLE IF EXISTS $table")
}
if (!db.inTransaction()) {
db.execSQL("VACUUM")
}
}
}
sealed class Subject { sealed class Subject {
data object Repositories : Subject() data object Repositories : Subject()
data class Repository(val id: Long) : Subject() data class Repository(val id: Long) : Subject()
@ -364,7 +207,7 @@ object Database {
} }
} }
private fun SQLiteDatabase.query( fun SQLiteDatabase.query(
table: String, table: String,
columns: Array<String>? = null, columns: Array<String>? = null,
selection: Pair<String, Array<String>>? = null, selection: Pair<String, Array<String>>? = null,

View File

@ -0,0 +1,176 @@
package com.looker.droidify.database.table
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import androidx.core.database.sqlite.transaction
import com.looker.droidify.database.Database.RepositoryAdapter
import com.looker.droidify.database.Database.Schema
import com.looker.droidify.database.Database.jsonParse
import com.looker.droidify.database.Database.query
import com.looker.droidify.model.Repository
import com.looker.droidify.utility.common.extension.asSequence
import com.looker.droidify.utility.common.extension.firstOrNull
import com.looker.droidify.utility.serialization.repository
private const val DB_LEGACY_NAME = "droidify"
private const val DB_LEGACY_VERSION = 6
class DatabaseHelper(context: Context) :
SQLiteOpenHelper(context, DB_LEGACY_NAME, null, DB_LEGACY_VERSION) {
var created = false
private set
var updated = false
private set
override fun onCreate(db: SQLiteDatabase) {
// Create all tables
db.execSQL(Schema.Repository.formatCreateTable(Schema.Repository.name))
db.execSQL(Schema.Product.formatCreateTable(Schema.Product.name))
db.execSQL(Schema.Category.formatCreateTable(Schema.Category.name))
// Add default repositories for new database
db.addDefaultRepositories()
this.created = true
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
// Add newly added repositories when database version is upgraded
db.addNewlyAddedRepositories()
this.updated = true
}
override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
// Handle database downgrades if needed
onUpgrade(db, oldVersion, newVersion)
}
override fun onOpen(db: SQLiteDatabase) {
// Handle memory tables and indexes
db.execSQL("ATTACH DATABASE ':memory:' AS memory")
handleTables(db, Schema.Installed, Schema.Lock)
handleIndexes(
db,
Schema.Repository,
Schema.Product,
Schema.Category,
Schema.Installed,
Schema.Lock,
)
dropOldTables(db, Schema.Repository, Schema.Product, Schema.Category)
}
private fun SQLiteDatabase.addDefaultRepositories() {
// Add all default repositories for new database
transaction {
(Repository.defaultRepositories + Repository.newlyAdded)
.sortedBy { it.name }
.forEach { repo -> RepositoryAdapter.put(repo, database = this) }
}
}
private fun SQLiteDatabase.addNewlyAddedRepositories() {
// Add only newly added repositories, checking for existing ones
val existingRepos = query(
Schema.Repository.name,
columns = arrayOf(Schema.Repository.ROW_DATA),
selection = null,
signal = null,
).use { cursor ->
cursor.asSequence().mapNotNull {
val dataIndex = it.getColumnIndexOrThrow(Schema.Repository.ROW_DATA)
val data = it.getBlob(dataIndex)
try {
data.jsonParse { json -> json.repository() }.address
} catch (_: Exception) {
null
}
}.toSet()
}
// Only add repositories that don't already exist
val reposToAdd = Repository.newlyAdded.filter { repo ->
repo.address !in existingRepos
}
if (reposToAdd.isNotEmpty()) {
transaction {
reposToAdd.forEach { repo ->
RepositoryAdapter.put(repo, database = this)
}
}
}
}
private fun handleTables(db: SQLiteDatabase, vararg tables: Table): Boolean {
val shouldRecreate = tables.any { table ->
val sql = db.query(
"${table.databasePrefix}sqlite_master",
columns = arrayOf("sql"),
selection = Pair("type = ? AND name = ?", arrayOf("table", table.innerName)),
).use { it.firstOrNull()?.getString(0) }.orEmpty()
table.formatCreateTable(table.innerName) != sql
}
return shouldRecreate && run {
val shouldVacuum = tables.map {
db.execSQL("DROP TABLE IF EXISTS ${it.name}")
db.execSQL(it.formatCreateTable(it.name))
!it.memory
}
if (shouldVacuum.any { it } && !db.inTransaction()) {
db.execSQL("VACUUM")
}
true
}
}
private fun handleIndexes(db: SQLiteDatabase, vararg tables: Table) {
val shouldVacuum = tables.map { table ->
val sqls = db.query(
"${table.databasePrefix}sqlite_master",
columns = arrayOf("name", "sql"),
selection = Pair("type = ? AND tbl_name = ?", arrayOf("index", table.innerName)),
)
.use { cursor ->
cursor.asSequence()
.mapNotNull { it.getString(1)?.let { sql -> Pair(it.getString(0), sql) } }
.toList()
}
.filter { !it.first.startsWith("sqlite_") }
val createIndexes = table.createIndexPairFormatted?.let { listOf(it) }.orEmpty()
createIndexes.map { it.first } != sqls.map { it.second } && run {
for (name in sqls.map { it.first }) {
db.execSQL("DROP INDEX IF EXISTS $name")
}
for (createIndexPair in createIndexes) {
db.execSQL(createIndexPair.second)
}
!table.memory
}
}
if (shouldVacuum.any { it } && !db.inTransaction()) {
db.execSQL("VACUUM")
}
}
private fun dropOldTables(db: SQLiteDatabase, vararg neededTables: Table) {
val tables = db.query(
"sqlite_master",
columns = arrayOf("name"),
selection = Pair("type = ?", arrayOf("table")),
)
.use { cursor -> cursor.asSequence().mapNotNull { it.getString(0) }.toList() }
.filter { !it.startsWith("sqlite_") && !it.startsWith("android_") }
.toSet() - neededTables.mapNotNull { if (it.memory) null else it.name }.toSet()
if (tables.isNotEmpty()) {
for (table in tables) {
db.execSQL("DROP TABLE IF EXISTS $table")
}
if (!db.inTransaction()) {
db.execSQL("VACUUM")
}
}
}
}

View File

@ -0,0 +1,35 @@
package com.looker.droidify.database.table
import com.looker.droidify.database.trimAndJoin
interface Table {
val memory: Boolean
val innerName: String
val createTable: String
val createIndex: String?
get() = null
val databasePrefix: String
get() = if (memory) "memory." else ""
val name: String
get() = "$databasePrefix$innerName"
fun formatCreateTable(name: String): String {
return buildString(128) {
append("CREATE TABLE ")
append(name)
append(" (")
trimAndJoin(createTable)
append(")")
}
}
val createIndexPairFormatted: Pair<String, String>?
get() = createIndex?.let {
Pair(
"CREATE INDEX ${innerName}_index ON $innerName ($it)",
"CREATE INDEX ${name}_index ON $innerName ($it)",
)
}
}