From 908723ac8d02ceb4cc0ae35a73633f1a6e3110ca Mon Sep 17 00:00:00 2001 From: Flo Edelmann Date: Tue, 8 Apr 2025 22:07:02 +0200 Subject: [PATCH 01/16] Migrate to multiplatform SQLite library --- app/build.gradle.kts | 4 + .../streetcomplete/AndroidModule.kt | 10 +- .../streetcomplete/data/AndroidDatabase.kt | 227 -------------- .../data/StreetCompleteSQLiteOpenHelper.kt | 17 -- .../data/StreetCompleteDatabase.kt | 278 ++++++++++++++++++ 5 files changed, 288 insertions(+), 248 deletions(-) delete mode 100644 app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/AndroidDatabase.kt delete mode 100644 app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteSQLiteOpenHelper.kt create mode 100644 app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a45bdb784fd..805d622ecd8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -114,6 +114,10 @@ kotlin { // I/O implementation("org.jetbrains.kotlinx:kotlinx-io-core:0.9.0") + // SQLite + implementation("androidx.sqlite:sqlite:2.5.1") + implementation("androidx.sqlite:sqlite-bundled:2.5.1") + // HTTP client implementation("io.ktor:ktor-client-core:3.4.0") implementation("io.ktor:ktor-client-encoding:3.4.0") diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/AndroidModule.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/AndroidModule.kt index d43b5f468f8..7426e264f1a 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/AndroidModule.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/AndroidModule.kt @@ -1,11 +1,12 @@ package de.westnordost.streetcomplete +import android.content.Context +import androidx.sqlite.driver.bundled.BundledSQLiteDriver import com.russhwolf.settings.ObservableSettings import com.russhwolf.settings.SharedPreferencesSettings -import de.westnordost.streetcomplete.data.AndroidDatabase import de.westnordost.streetcomplete.data.CleanerWorker import de.westnordost.streetcomplete.data.Database -import de.westnordost.streetcomplete.data.StreetCompleteSQLiteOpenHelper +import de.westnordost.streetcomplete.data.StreetCompleteDatabase import de.westnordost.streetcomplete.data.connection.InternetConnectionState import de.westnordost.streetcomplete.data.download.DownloadController import de.westnordost.streetcomplete.data.download.DownloadControllerAndroid @@ -28,8 +29,9 @@ val androidModule = module { // Database on Android single { - val sqLite = StreetCompleteSQLiteOpenHelper(get(), ApplicationConstants.DATABASE_NAME) - AndroidDatabase(sqLite.writableDatabase) + val databaseFilePath = get().getDatabasePath(ApplicationConstants.DATABASE_NAME).path + val databaseConnection = BundledSQLiteDriver().open(databaseFilePath) + StreetCompleteDatabase(databaseConnection) } // Workmanager-based on Android diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/AndroidDatabase.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/AndroidDatabase.kt deleted file mode 100644 index 91c2764e11a..00000000000 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/AndroidDatabase.kt +++ /dev/null @@ -1,227 +0,0 @@ -package de.westnordost.streetcomplete.data - -import android.annotation.SuppressLint -import android.content.ContentValues -import android.database.Cursor -import android.database.sqlite.SQLiteDatabase -import android.database.sqlite.SQLiteDatabase.CONFLICT_ABORT -import android.database.sqlite.SQLiteDatabase.CONFLICT_FAIL -import android.database.sqlite.SQLiteDatabase.CONFLICT_IGNORE -import android.database.sqlite.SQLiteDatabase.CONFLICT_NONE -import android.database.sqlite.SQLiteDatabase.CONFLICT_REPLACE -import android.database.sqlite.SQLiteDatabase.CONFLICT_ROLLBACK -import android.database.sqlite.SQLiteStatement -import androidx.core.database.getBlobOrNull -import androidx.core.database.getDoubleOrNull -import androidx.core.database.getFloatOrNull -import androidx.core.database.getIntOrNull -import androidx.core.database.getLongOrNull -import androidx.core.database.getStringOrNull -import androidx.core.database.sqlite.transaction -import de.westnordost.streetcomplete.data.ConflictAlgorithm.ABORT -import de.westnordost.streetcomplete.data.ConflictAlgorithm.FAIL -import de.westnordost.streetcomplete.data.ConflictAlgorithm.IGNORE -import de.westnordost.streetcomplete.data.ConflictAlgorithm.REPLACE -import de.westnordost.streetcomplete.data.ConflictAlgorithm.ROLLBACK - -/** Implementation of Database using android's SQLiteOpenHelper. Since the minimum API version is - * 21, the minimum SQLite version is 3.8. */ -@SuppressLint("Recycle") -class AndroidDatabase(private val db: SQLiteDatabase) : Database { - - override fun exec(sql: String, args: Array?) { - if (args == null) db.execSQL(sql) else db.execSQL(sql, args) - } - - override fun rawQuery( - sql: String, - args: Array?, - transform: (CursorPosition) -> T - ): List { - val strArgs = args?.primitivesArrayToStringArray() - return db.rawQuery(sql, strArgs).toSequence(transform).toList() - } - - override fun queryOne( - table: String, - columns: Array?, - where: String?, - args: Array?, - groupBy: String?, - having: String?, - orderBy: String?, - transform: (CursorPosition) -> T - ): T? { - val strArgs = args?.primitivesArrayToStringArray() - return db.query(false, table, columns, where, strArgs, groupBy, having, orderBy, "1").toSequence(transform).firstOrNull() - } - - override fun query( - table: String, - columns: Array?, - where: String?, - args: Array?, - groupBy: String?, - having: String?, - orderBy: String?, - limit: Int?, - distinct: Boolean, - transform: (CursorPosition) -> T - ): List { - val strArgs = args?.primitivesArrayToStringArray() - return db.query(distinct, table, columns, where, strArgs, groupBy, having, orderBy, limit?.toString()).toSequence(transform).toList() - } - - override fun insert( - table: String, - values: Collection>, - conflictAlgorithm: ConflictAlgorithm? - ): Long = - db.insertWithOnConflict( - table, - null, - values.toContentValues(), - conflictAlgorithm.toConstant() - ) - - override fun insertMany( - table: String, - columnNames: Array, - valuesList: Iterable>, - conflictAlgorithm: ConflictAlgorithm? - ): List { - val conflictStr = conflictAlgorithm.toSQL() - val columnNamesStr = columnNames.joinToString(",") - val placeholdersStr = Array(columnNames.size) { "?" }.joinToString(",") - val stmt = db.compileStatement("INSERT $conflictStr INTO $table ($columnNamesStr) VALUES ($placeholdersStr)") - val result = ArrayList() - transaction { - for (values in valuesList) { - require(values.size == columnNames.size) - for ((i, value) in values.withIndex()) { - // Android SQLiteProgram.bind* indices are 1-based - stmt.bind(i + 1, value) - } - val rowId = stmt.executeInsert() - result.add(rowId) - stmt.clearBindings() - } - stmt.close() - } - return result - } - - override fun update( - table: String, - values: Collection>, - where: String?, - args: Array?, - conflictAlgorithm: ConflictAlgorithm? - ): Int = - db.updateWithOnConflict( - table, - values.toContentValues(), - where, - args?.primitivesArrayToStringArray(), - conflictAlgorithm.toConstant() - ) - - override fun delete(table: String, where: String?, args: Array?): Int { - val strArgs = args?.primitivesArrayToStringArray() - return db.delete(table, where, strArgs) - } - - override fun transaction(block: () -> T): T = db.transaction { block() } -} - -private fun Array.primitivesArrayToStringArray() = Array(size) { i -> - primitiveToString(this[i]) -} - -private fun primitiveToString(any: Any): String = when (any) { - is Short, is Int, is Long, is Float, is Double -> any.toString() - is String -> any - else -> throw IllegalArgumentException("Cannot bind $any: Must be either Int, Long, Float, Double or String") -} - -private inline fun Cursor.toSequence(crossinline transform: (CursorPosition) -> T): List = use { cursor -> - val c = AndroidCursorPosition(cursor) - cursor.moveToFirst() - val result = ArrayList(cursor.count) - while (!cursor.isAfterLast) { - result.add(transform(c)) - cursor.moveToNext() - } - return result -} - -class AndroidCursorPosition(private val cursor: Cursor) : CursorPosition { - override fun getInt(columnName: String): Int = cursor.getInt(index(columnName)) - override fun getLong(columnName: String): Long = cursor.getLong(index(columnName)) - override fun getDouble(columnName: String): Double = cursor.getDouble(index(columnName)) - override fun getFloat(columnName: String): Float = cursor.getFloat(index(columnName)) - override fun getBlob(columnName: String): ByteArray = cursor.getBlob(index(columnName)) - override fun getString(columnName: String): String = cursor.getString(index(columnName)) - override fun getIntOrNull(columnName: String): Int? = cursor.getIntOrNull(index(columnName)) - override fun getLongOrNull(columnName: String): Long? = cursor.getLongOrNull(index(columnName)) - override fun getDoubleOrNull(columnName: String): Double? = cursor.getDoubleOrNull(index(columnName)) - override fun getFloatOrNull(columnName: String): Float? = cursor.getFloatOrNull(index(columnName)) - override fun getBlobOrNull(columnName: String): ByteArray? = cursor.getBlobOrNull(index(columnName)) - override fun getStringOrNull(columnName: String): String? = cursor.getStringOrNull(index(columnName)) - - private fun index(columnName: String): Int = cursor.getColumnIndexOrThrow(columnName) -} - -private fun Collection>.toContentValues() = ContentValues(size).also { - for ((key, value) in this) { - when (value) { - null -> it.putNull(key) - is String -> it.put(key, value) - is Short -> it.put(key, value) - is Int -> it.put(key, value) - is Long -> it.put(key, value) - is Float -> it.put(key, value) - is Double -> it.put(key, value) - is ByteArray -> it.put(key, value) - else -> { - val valueType = value.javaClass.canonicalName - throw IllegalArgumentException("Illegal value type $valueType for key \"$key\"") - } - } - } -} - -private fun ConflictAlgorithm?.toConstant() = when (this) { - ROLLBACK -> CONFLICT_ROLLBACK - ABORT -> CONFLICT_ABORT - FAIL -> CONFLICT_FAIL - IGNORE -> CONFLICT_IGNORE - REPLACE -> CONFLICT_REPLACE - null -> CONFLICT_NONE -} - -private fun ConflictAlgorithm?.toSQL() = when (this) { - ROLLBACK -> " OR ROLLBACK " - ABORT -> " OR ABORT " - FAIL -> " OR FAIL " - IGNORE -> " OR IGNORE " - REPLACE -> " OR REPLACE " - null -> "" -} - -private fun SQLiteStatement.bind(i: Int, value: Any?) { - when (value) { - null -> bindNull(i) - is String -> bindString(i, value) - is Double -> bindDouble(i, value) - is Long -> bindLong(i, value) - is ByteArray -> bindBlob(i, value) - is Int -> bindLong(i, value.toLong()) - is Short -> bindLong(i, value.toLong()) - is Float -> bindDouble(i, value.toDouble()) - else -> { - val valueType = value.javaClass.canonicalName - throw IllegalArgumentException("Illegal value type $valueType at column $i") - } - } -} diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteSQLiteOpenHelper.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteSQLiteOpenHelper.kt deleted file mode 100644 index e95fc04d48f..00000000000 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteSQLiteOpenHelper.kt +++ /dev/null @@ -1,17 +0,0 @@ -package de.westnordost.streetcomplete.data - -import android.content.Context -import android.database.sqlite.SQLiteDatabase -import android.database.sqlite.SQLiteOpenHelper - -class StreetCompleteSQLiteOpenHelper(context: Context, dbName: String) : - SQLiteOpenHelper(context, dbName, null, DatabaseInitializer.DB_VERSION) { - - override fun onCreate(db: SQLiteDatabase) { - DatabaseInitializer.onCreate(AndroidDatabase(db)) - } - - override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { - DatabaseInitializer.onUpgrade(AndroidDatabase(db), oldVersion, newVersion) - } -} diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt new file mode 100644 index 00000000000..dcf5402385f --- /dev/null +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt @@ -0,0 +1,278 @@ +package de.westnordost.streetcomplete.data + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteStatement +import androidx.sqlite.execSQL +import de.westnordost.streetcomplete.data.ConflictAlgorithm.ABORT +import de.westnordost.streetcomplete.data.ConflictAlgorithm.FAIL +import de.westnordost.streetcomplete.data.ConflictAlgorithm.IGNORE +import de.westnordost.streetcomplete.data.ConflictAlgorithm.REPLACE +import de.westnordost.streetcomplete.data.ConflictAlgorithm.ROLLBACK + +class StreetCompleteDatabase(private val databaseConnection: SQLiteConnection) : Database { + init { + val oldVersion = databaseConnection.prepare("PRAGMA user_version").use { statement -> + statement.toSequence { it.getInt("user_version") }.single() + } + val newVersion = DatabaseInitializer.DB_VERSION + + if (oldVersion == 0) { + DatabaseInitializer.onCreate(this) + } else if (oldVersion < newVersion) { + DatabaseInitializer.onUpgrade(this, oldVersion, newVersion) + databaseConnection.execSQL("PRAGMA user_version = $newVersion") + } + } + + override fun exec(sql: String, args: Array?) { + databaseConnection.prepare(sql).use { statement -> + statement.bindAll(args) + statement.step() + } + } + + override fun rawQuery( + sql: String, + args: Array?, + transform: (CursorPosition) -> T, + ): List = + databaseConnection.prepare(sql).use { statement -> + statement.bindAll(args) + statement.toSequence(transform).toList() + } + + override fun queryOne( + table: String, + columns: Array?, + where: String?, + args: Array?, + groupBy: String?, + having: String?, + orderBy: String?, + transform: (CursorPosition) -> T, + ): T? = + databaseConnection.prepareQuery(false, table, columns, where, groupBy, having, orderBy, 1).use { statement -> + statement.bindAll(args) + statement.toSequence(transform).firstOrNull() + } + + override fun query( + table: String, + columns: Array?, + where: String?, + args: Array?, + groupBy: String?, + having: String?, + orderBy: String?, + limit: Int?, + distinct: Boolean, + transform: (CursorPosition) -> T, + ): List = + databaseConnection.prepareQuery(distinct, table, columns, where, groupBy, having, orderBy, limit).use { statement -> + statement.bindAll(args) + statement.toSequence(transform).toList() + } + + override fun insert( + table: String, + values: Collection>, + conflictAlgorithm: ConflictAlgorithm?, + ): Long = + databaseConnection.prepareInsert(table, values.map { it.first }, conflictAlgorithm).use { statement -> + statement.bindAll(values.map { it.second }.toTypedArray()) + statement.executeInsert() + } + + override fun insertMany( + table: String, + columnNames: Array, + valuesList: Iterable>, + conflictAlgorithm: ConflictAlgorithm?, + ): List { + val statement = databaseConnection.prepareInsert(table, columnNames.toList(), conflictAlgorithm) + val result = ArrayList() + transaction { + for (values in valuesList) { + require(values.size == columnNames.size) + statement.bindAll(values) + val rowId = statement.executeInsert() + result.add(rowId) + statement.clearBindings() + statement.reset() + } + statement.close() + } + return result + } + + override fun update( + table: String, + values: Collection>, + where: String?, + args: Array?, + conflictAlgorithm: ConflictAlgorithm?, + ): Int = + databaseConnection.prepareUpdate(table, values, where, conflictAlgorithm).use { statement -> + statement.bindAll(args) + statement.toSequence { }.count() + } + + override fun delete(table: String, where: String?, args: Array?): Int = + databaseConnection.prepareDelete(table, where).use { statement -> + statement.bindAll(args) + statement.toSequence { }.count() + } + + override fun transaction(block: () -> T): T { + databaseConnection.execSQL("BEGIN IMMEDIATE TRANSACTION") + try { + val result = block() + databaseConnection.execSQL("END TRANSACTION") + return result + } catch (t: Throwable) { + databaseConnection.execSQL("ROLLBACK TRANSACTION") + throw t + } + } +} + +class SQLiteCursorPosition(private val statement: SQLiteStatement) : CursorPosition { + override fun getInt(columnName: String): Int = statement.getInt(index(columnName)) + override fun getLong(columnName: String): Long = statement.getLong(index(columnName)) + override fun getDouble(columnName: String): Double = statement.getDouble(index(columnName)) + override fun getFloat(columnName: String): Float = statement.getFloat(index(columnName)) + override fun getBlob(columnName: String): ByteArray = statement.getBlob(index(columnName)) + override fun getString(columnName: String): String = statement.getText(index(columnName)) + override fun getIntOrNull(columnName: String): Int? = statement.getIntOrNull(index(columnName)) + override fun getLongOrNull(columnName: String): Long? = statement.getLongOrNull(index(columnName)) + override fun getDoubleOrNull(columnName: String): Double? = statement.getDoubleOrNull(index(columnName)) + override fun getFloatOrNull(columnName: String): Float? = statement.getFloatOrNull(index(columnName)) + override fun getBlobOrNull(columnName: String): ByteArray? = statement.getBlobOrNull(index(columnName)) + override fun getStringOrNull(columnName: String): String? = statement.getTextOrNull(index(columnName)) + + private val columnNames by lazy { statement.getColumnNames() } + + private fun index(columnName: String): Int { + val index = columnNames.indexOf(columnName) + require(index != -1) { "Column $columnName not found" } + return index + } +} + +private fun ConflictAlgorithm?.toSql() = when (this) { + ROLLBACK -> "OR ROLLBACK" + ABORT -> "OR ABORT" + FAIL -> "OR FAIL" + IGNORE -> "OR IGNORE" + REPLACE -> "OR REPLACE" + null -> "" +} + +private fun SQLiteConnection.prepareQuery( + distinct: Boolean, + table: String, + columns: Array?, + where: String?, + groupBy: String?, + having: String?, + orderBy: String?, + limit: Int?, +): SQLiteStatement { + require (having.isNullOrBlank() || !groupBy.isNullOrBlank()) { + "`having` clauses are only permitted when using a `groupBy clause" + } + + val columnNames = if (columns.isNullOrEmpty()) "*" else columns.joinToString(", ") + var sql = "SELECT" + sql += if (distinct) " DISTINCT" else "" + sql += " $columnNames FROM $table" + where?.let { sql += " WHERE $it" } + groupBy?.let { sql += " GROUP BY $it" } + having?.let { sql += " HAVING $it" } + orderBy?.let { sql += " ORDER BY $it" } + limit?.let { sql += " LIMIT $limit" } + + return prepare(sql) +} + +private fun getPlaceholdersString(count: Int) = + if (count == 0) "NULL" else Array(count) { "?" }.joinToString(", ") + +private fun SQLiteConnection.prepareInsert( + table: String, + columns: List, + conflictAlgorithm: ConflictAlgorithm?, +): SQLiteStatement { + val conflictSql = conflictAlgorithm.toSql() + val columnNames = columns.joinToString(", ") + val placeholders = getPlaceholdersString(columns.size) + val sql = "INSERT $conflictSql INTO $table ($columnNames) VALUES ($placeholders) RETURNING RowId" + + return prepare(sql) +} + +private fun SQLiteConnection.prepareUpdate( + table: String, + values: Collection>, + where: String?, + conflictAlgorithm: ConflictAlgorithm?, +): SQLiteStatement { + val conflictSql = conflictAlgorithm.toSql() + val placeholders = values.map { it.first + "=?" } + val whereClause = if (where.isNullOrBlank()) "" else "WHERE $where" + val sql = "UPDATE $conflictSql $table SET $placeholders $whereClause" + + return prepare(sql) +} + +private fun SQLiteConnection.prepareDelete( + table: String, + where: String?, +): SQLiteStatement { + val whereClause = if (where.isNullOrBlank()) "" else "WHERE $where" + val sql = "DELETE FROM $table $whereClause" + + return prepare(sql) +} + +private fun SQLiteStatement.bind(i: Int, value: Any?) { + when (value) { + null -> bindNull(i) + is String -> bindText(i, value) + is Double -> bindDouble(i, value) + is Long -> bindLong(i, value) + is ByteArray -> bindBlob(i, value) + is Int -> bindInt(i, value) + is Float -> bindFloat(i, value) + else -> { + val valueType = value.javaClass.canonicalName + throw IllegalArgumentException("Illegal value type $valueType at column $i") + } + } +} + +private fun SQLiteStatement.bindAll(args: Array?) { + if (args != null) { + for ((index, value) in args.withIndex()) { + bind(index + 1, value) + } + } +} + +private fun SQLiteStatement.toSequence(transform: (CursorPosition) -> T): Sequence { + val c = SQLiteCursorPosition(this) + return sequence { + while (step()) { + yield(transform(c)) + } + } +} +private fun SQLiteStatement.executeInsert(): Long = + toSequence { it.getLong("rowid") }.single() + +private fun SQLiteStatement.getIntOrNull(index: Int) = if (isNull(index)) null else getInt(index) +private fun SQLiteStatement.getLongOrNull(index: Int) = if (isNull(index)) null else getLong(index) +private fun SQLiteStatement.getDoubleOrNull(index: Int) = if (isNull(index)) null else getDouble(index) +private fun SQLiteStatement.getFloatOrNull(index: Int) = if (isNull(index)) null else getFloat(index) +private fun SQLiteStatement.getBlobOrNull(index: Int) = if (isNull(index)) null else getBlob(index) +private fun SQLiteStatement.getTextOrNull(index: Int) = if (isNull(index)) null else getText(index) From c40e566a736133d8705c286045af361225f92c90 Mon Sep 17 00:00:00 2001 From: Flo Edelmann Date: Mon, 16 Mar 2026 19:11:16 +0100 Subject: [PATCH 02/16] Update SQLite dependency to v2.6.2 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 805d622ecd8..802722f689f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -115,8 +115,8 @@ kotlin { implementation("org.jetbrains.kotlinx:kotlinx-io-core:0.9.0") // SQLite - implementation("androidx.sqlite:sqlite:2.5.1") - implementation("androidx.sqlite:sqlite-bundled:2.5.1") + implementation("androidx.sqlite:sqlite:2.6.2") + implementation("androidx.sqlite:sqlite-bundled:2.6.2") // HTTP client implementation("io.ktor:ktor-client-core:3.4.0") From b68488e4c2511dd5cd573f5f87d99056a0147b84 Mon Sep 17 00:00:00 2001 From: Flo Edelmann Date: Mon, 16 Mar 2026 19:35:23 +0100 Subject: [PATCH 03/16] Speed up column index lookup with Map --- .../streetcomplete/data/StreetCompleteDatabase.kt | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt index dcf5402385f..cbe5fe89297 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt @@ -150,13 +150,12 @@ class SQLiteCursorPosition(private val statement: SQLiteStatement) : CursorPosit override fun getBlobOrNull(columnName: String): ByteArray? = statement.getBlobOrNull(index(columnName)) override fun getStringOrNull(columnName: String): String? = statement.getTextOrNull(index(columnName)) - private val columnNames by lazy { statement.getColumnNames() } - - private fun index(columnName: String): Int { - val index = columnNames.indexOf(columnName) - require(index != -1) { "Column $columnName not found" } - return index + private val columnIndices: Map by lazy { + statement.getColumnNames().withIndex().associate { (i, name) -> name to i } } + + private fun index(columnName: String): Int = + columnIndices[columnName] ?: throw IllegalArgumentException("Column $columnName not found") } private fun ConflictAlgorithm?.toSql() = when (this) { From 10fc41aabe23ab34de3c7480ede2b38bc5fa94ff Mon Sep 17 00:00:00 2001 From: Flo Edelmann Date: Mon, 16 Mar 2026 19:43:45 +0100 Subject: [PATCH 04/16] Use `StringBuilder` --- .../data/StreetCompleteDatabase.kt | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt index cbe5fe89297..309f619bda4 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt @@ -182,16 +182,16 @@ private fun SQLiteConnection.prepareQuery( } val columnNames = if (columns.isNullOrEmpty()) "*" else columns.joinToString(", ") - var sql = "SELECT" - sql += if (distinct) " DISTINCT" else "" - sql += " $columnNames FROM $table" - where?.let { sql += " WHERE $it" } - groupBy?.let { sql += " GROUP BY $it" } - having?.let { sql += " HAVING $it" } - orderBy?.let { sql += " ORDER BY $it" } - limit?.let { sql += " LIMIT $limit" } + val sql = StringBuilder("SELECT") + if (distinct) sql.append(" DISTINCT") + sql.append(" $columnNames FROM $table") + where?.let { sql.append(" WHERE $it") } + groupBy?.let { sql.append(" GROUP BY $it") } + having?.let { sql.append(" HAVING $it") } + orderBy?.let { sql.append(" ORDER BY $it") } + limit?.let { sql.append(" LIMIT $it") } - return prepare(sql) + return prepare(sql.toString()) } private fun getPlaceholdersString(count: Int) = From bfa723cca5ffec949e467f3fee96f63fe8f274c0 Mon Sep 17 00:00:00 2001 From: Flo Edelmann Date: Mon, 16 Mar 2026 20:13:57 +0100 Subject: [PATCH 05/16] Fix update/delete return values --- .../westnordost/streetcomplete/data/StreetCompleteDatabase.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt index 309f619bda4..e27f8cdc55e 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt @@ -219,7 +219,7 @@ private fun SQLiteConnection.prepareUpdate( val conflictSql = conflictAlgorithm.toSql() val placeholders = values.map { it.first + "=?" } val whereClause = if (where.isNullOrBlank()) "" else "WHERE $where" - val sql = "UPDATE $conflictSql $table SET $placeholders $whereClause" + val sql = "UPDATE $conflictSql $table SET $placeholders $whereClause RETURNING 1" return prepare(sql) } @@ -229,7 +229,7 @@ private fun SQLiteConnection.prepareDelete( where: String?, ): SQLiteStatement { val whereClause = if (where.isNullOrBlank()) "" else "WHERE $where" - val sql = "DELETE FROM $table $whereClause" + val sql = "DELETE FROM $table $whereClause RETURNING 1" return prepare(sql) } From 57bd664c1d7180b0d373e581fb9f09b64606002e Mon Sep 17 00:00:00 2001 From: Flo Edelmann Date: Mon, 16 Mar 2026 22:37:13 +0100 Subject: [PATCH 06/16] Prevent concurrent database accesses --- .../data/StreetCompleteDatabase.kt | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt index e27f8cdc55e..de4e8b07886 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt @@ -8,8 +8,12 @@ import de.westnordost.streetcomplete.data.ConflictAlgorithm.FAIL import de.westnordost.streetcomplete.data.ConflictAlgorithm.IGNORE import de.westnordost.streetcomplete.data.ConflictAlgorithm.REPLACE import de.westnordost.streetcomplete.data.ConflictAlgorithm.ROLLBACK +import kotlinx.atomicfu.locks.ReentrantLock +import kotlinx.atomicfu.locks.withLock class StreetCompleteDatabase(private val databaseConnection: SQLiteConnection) : Database { + private val lock = ReentrantLock() + init { val oldVersion = databaseConnection.prepare("PRAGMA user_version").use { statement -> statement.toSequence { it.getInt("user_version") }.single() @@ -24,7 +28,7 @@ class StreetCompleteDatabase(private val databaseConnection: SQLiteConnection) : } } - override fun exec(sql: String, args: Array?) { + override fun exec(sql: String, args: Array?): Unit = lock.withLock { databaseConnection.prepare(sql).use { statement -> statement.bindAll(args) statement.step() @@ -35,11 +39,12 @@ class StreetCompleteDatabase(private val databaseConnection: SQLiteConnection) : sql: String, args: Array?, transform: (CursorPosition) -> T, - ): List = + ): List = lock.withLock { databaseConnection.prepare(sql).use { statement -> statement.bindAll(args) statement.toSequence(transform).toList() } + } override fun queryOne( table: String, @@ -50,11 +55,12 @@ class StreetCompleteDatabase(private val databaseConnection: SQLiteConnection) : having: String?, orderBy: String?, transform: (CursorPosition) -> T, - ): T? = + ): T? = lock.withLock { databaseConnection.prepareQuery(false, table, columns, where, groupBy, having, orderBy, 1).use { statement -> statement.bindAll(args) statement.toSequence(transform).firstOrNull() } + } override fun query( table: String, @@ -67,28 +73,30 @@ class StreetCompleteDatabase(private val databaseConnection: SQLiteConnection) : limit: Int?, distinct: Boolean, transform: (CursorPosition) -> T, - ): List = + ): List = lock.withLock { databaseConnection.prepareQuery(distinct, table, columns, where, groupBy, having, orderBy, limit).use { statement -> statement.bindAll(args) statement.toSequence(transform).toList() } + } override fun insert( table: String, values: Collection>, conflictAlgorithm: ConflictAlgorithm?, - ): Long = + ): Long = lock.withLock { databaseConnection.prepareInsert(table, values.map { it.first }, conflictAlgorithm).use { statement -> statement.bindAll(values.map { it.second }.toTypedArray()) statement.executeInsert() } + } override fun insertMany( table: String, columnNames: Array, valuesList: Iterable>, conflictAlgorithm: ConflictAlgorithm?, - ): List { + ): List = lock.withLock { val statement = databaseConnection.prepareInsert(table, columnNames.toList(), conflictAlgorithm) val result = ArrayList() transaction { @@ -111,19 +119,21 @@ class StreetCompleteDatabase(private val databaseConnection: SQLiteConnection) : where: String?, args: Array?, conflictAlgorithm: ConflictAlgorithm?, - ): Int = + ): Int = lock.withLock { databaseConnection.prepareUpdate(table, values, where, conflictAlgorithm).use { statement -> statement.bindAll(args) statement.toSequence { }.count() } + } - override fun delete(table: String, where: String?, args: Array?): Int = + override fun delete(table: String, where: String?, args: Array?): Int = lock.withLock { databaseConnection.prepareDelete(table, where).use { statement -> statement.bindAll(args) statement.toSequence { }.count() } + } - override fun transaction(block: () -> T): T { + override fun transaction(block: () -> T): T = lock.withLock { databaseConnection.execSQL("BEGIN IMMEDIATE TRANSACTION") try { val result = block() From b3a74f3ad6963402c018b63537d801a40c28f65d Mon Sep 17 00:00:00 2001 From: Flo Edelmann Date: Mon, 16 Mar 2026 22:37:47 +0100 Subject: [PATCH 07/16] Support nested transactions --- .../data/StreetCompleteDatabase.kt | 41 ++++++++++++------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt index de4e8b07886..c3090e5e6ba 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt @@ -13,6 +13,7 @@ import kotlinx.atomicfu.locks.withLock class StreetCompleteDatabase(private val databaseConnection: SQLiteConnection) : Database { private val lock = ReentrantLock() + private var transactionDepth = 0 init { val oldVersion = databaseConnection.prepare("PRAGMA user_version").use { statement -> @@ -97,20 +98,20 @@ class StreetCompleteDatabase(private val databaseConnection: SQLiteConnection) : valuesList: Iterable>, conflictAlgorithm: ConflictAlgorithm?, ): List = lock.withLock { - val statement = databaseConnection.prepareInsert(table, columnNames.toList(), conflictAlgorithm) - val result = ArrayList() - transaction { - for (values in valuesList) { - require(values.size == columnNames.size) - statement.bindAll(values) - val rowId = statement.executeInsert() - result.add(rowId) - statement.clearBindings() - statement.reset() + databaseConnection.prepareInsert(table, columnNames.toList(), conflictAlgorithm).use { statement -> + val result = ArrayList() + transaction { + for (values in valuesList) { + require(values.size == columnNames.size) + statement.bindAll(values) + val rowId = statement.executeInsert() + result.add(rowId) + statement.clearBindings() + statement.reset() + } } - statement.close() + result } - return result } override fun update( @@ -134,14 +135,24 @@ class StreetCompleteDatabase(private val databaseConnection: SQLiteConnection) : } override fun transaction(block: () -> T): T = lock.withLock { - databaseConnection.execSQL("BEGIN IMMEDIATE TRANSACTION") + val isOutermost = transactionDepth == 0 + transactionDepth++ try { + if (isOutermost) { + databaseConnection.execSQL("BEGIN IMMEDIATE TRANSACTION") + } val result = block() - databaseConnection.execSQL("END TRANSACTION") + if (isOutermost) { + databaseConnection.execSQL("END TRANSACTION") + } return result } catch (t: Throwable) { - databaseConnection.execSQL("ROLLBACK TRANSACTION") + if (isOutermost) { + databaseConnection.execSQL("ROLLBACK TRANSACTION") + } throw t + } finally { + transactionDepth-- } } } From fde6d30b95811dc4c8eb42558454505c93d251c9 Mon Sep 17 00:00:00 2001 From: Flo Edelmann Date: Mon, 16 Mar 2026 22:40:09 +0100 Subject: [PATCH 08/16] Rename to uppercase ROWID for consistency --- .../westnordost/streetcomplete/data/StreetCompleteDatabase.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt index c3090e5e6ba..8ec5b9c915d 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt @@ -226,7 +226,7 @@ private fun SQLiteConnection.prepareInsert( val conflictSql = conflictAlgorithm.toSql() val columnNames = columns.joinToString(", ") val placeholders = getPlaceholdersString(columns.size) - val sql = "INSERT $conflictSql INTO $table ($columnNames) VALUES ($placeholders) RETURNING RowId" + val sql = "INSERT $conflictSql INTO $table ($columnNames) VALUES ($placeholders) RETURNING ROWID" return prepare(sql) } From 99f90dd388e1ec9b0a18c06cccdb47ed59747d0b Mon Sep 17 00:00:00 2001 From: Flo Edelmann Date: Mon, 16 Mar 2026 22:40:34 +0100 Subject: [PATCH 09/16] Fix inserted row ID return value --- .../westnordost/streetcomplete/data/StreetCompleteDatabase.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt index 8ec5b9c915d..04d753bc953 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt @@ -287,9 +287,7 @@ private fun SQLiteStatement.toSequence(transform: (CursorPosition) -> T): Se } } } -private fun SQLiteStatement.executeInsert(): Long = - toSequence { it.getLong("rowid") }.single() - +private fun SQLiteStatement.executeInsert(): Long = if (step()) getLong(0) else -1 private fun SQLiteStatement.getIntOrNull(index: Int) = if (isNull(index)) null else getInt(index) private fun SQLiteStatement.getLongOrNull(index: Int) = if (isNull(index)) null else getLong(index) private fun SQLiteStatement.getDoubleOrNull(index: Int) = if (isNull(index)) null else getDouble(index) From 85bdd4968a3abeccea763b8b1fbc34a2cf6046f5 Mon Sep 17 00:00:00 2001 From: Flo Edelmann Date: Mon, 16 Mar 2026 22:41:10 +0100 Subject: [PATCH 10/16] Enable write-ahead logging see https://sqlite.org/wal.html --- .../de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt index 04d753bc953..6ec47fa86cd 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt @@ -16,6 +16,7 @@ class StreetCompleteDatabase(private val databaseConnection: SQLiteConnection) : private var transactionDepth = 0 init { + databaseConnection.execSQL("PRAGMA journal_mode=WAL") val oldVersion = databaseConnection.prepare("PRAGMA user_version").use { statement -> statement.toSequence { it.getInt("user_version") }.single() } From f5a62edbc8434c1179e957ec8fbfb9824476a396 Mon Sep 17 00:00:00 2001 From: Flo Edelmann Date: Mon, 16 Mar 2026 22:44:10 +0100 Subject: [PATCH 11/16] Fix class name in error message --- .../westnordost/streetcomplete/data/StreetCompleteDatabase.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt index 6ec47fa86cd..3510487f017 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt @@ -266,7 +266,7 @@ private fun SQLiteStatement.bind(i: Int, value: Any?) { is Int -> bindInt(i, value) is Float -> bindFloat(i, value) else -> { - val valueType = value.javaClass.canonicalName + val valueType = value::class.simpleName throw IllegalArgumentException("Illegal value type $valueType at column $i") } } From a323c2a25f47cea13d1cc05e735592593ecb1a33 Mon Sep 17 00:00:00 2001 From: Flo Edelmann Date: Mon, 16 Mar 2026 23:04:47 +0100 Subject: [PATCH 12/16] Fix update args --- .../streetcomplete/data/StreetCompleteDatabase.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt index 3510487f017..002060db3ab 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt @@ -123,7 +123,9 @@ class StreetCompleteDatabase(private val databaseConnection: SQLiteConnection) : conflictAlgorithm: ConflictAlgorithm?, ): Int = lock.withLock { databaseConnection.prepareUpdate(table, values, where, conflictAlgorithm).use { statement -> - statement.bindAll(args) + val valueArgs = values.map { it.second } + val allArgs = (valueArgs + args.orEmpty()).toTypedArray() + statement.bindAll(allArgs) statement.toSequence { }.count() } } @@ -239,7 +241,7 @@ private fun SQLiteConnection.prepareUpdate( conflictAlgorithm: ConflictAlgorithm?, ): SQLiteStatement { val conflictSql = conflictAlgorithm.toSql() - val placeholders = values.map { it.first + "=?" } + val placeholders = values.joinToString(", ") { it.first + "=?" } val whereClause = if (where.isNullOrBlank()) "" else "WHERE $where" val sql = "UPDATE $conflictSql $table SET $placeholders $whereClause RETURNING 1" From c04d102ea8644b070649ecf4ecccf116efaf0990 Mon Sep 17 00:00:00 2001 From: Flo Edelmann Date: Mon, 16 Mar 2026 23:28:51 +0100 Subject: [PATCH 13/16] Fix database upgrade for fresh install --- .../streetcomplete/data/StreetCompleteDatabase.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt index 002060db3ab..1d8e4362aeb 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt @@ -22,10 +22,12 @@ class StreetCompleteDatabase(private val databaseConnection: SQLiteConnection) : } val newVersion = DatabaseInitializer.DB_VERSION - if (oldVersion == 0) { - DatabaseInitializer.onCreate(this) - } else if (oldVersion < newVersion) { - DatabaseInitializer.onUpgrade(this, oldVersion, newVersion) + if (oldVersion < newVersion) { + if (oldVersion == 0) { + DatabaseInitializer.onCreate(this) + } else { + DatabaseInitializer.onUpgrade(this, oldVersion, newVersion) + } databaseConnection.execSQL("PRAGMA user_version = $newVersion") } } From c0459babb0513b9b22bb62cc53111d7d29addf2d Mon Sep 17 00:00:00 2001 From: Flo Edelmann Date: Mon, 16 Mar 2026 23:48:13 +0100 Subject: [PATCH 14/16] Update tests --- .../data/ApplicationDbTestCase.kt | 21 +++++++++---------- .../util/ktx/SQLiteDatabaseKtTest.kt | 4 ++-- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/data/ApplicationDbTestCase.kt b/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/data/ApplicationDbTestCase.kt index 545fecd0ce8..b3d9e07ce40 100644 --- a/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/data/ApplicationDbTestCase.kt +++ b/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/data/ApplicationDbTestCase.kt @@ -1,6 +1,7 @@ package de.westnordost.streetcomplete.data -import android.database.sqlite.SQLiteOpenHelper +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.test.platform.app.InstrumentationRegistry import kotlin.test.AfterTest import kotlin.test.BeforeTest @@ -8,25 +9,23 @@ import kotlin.test.Test import kotlin.test.assertNotNull open class ApplicationDbTestCase { - protected lateinit var dbHelper: SQLiteOpenHelper protected lateinit var database: Database + private lateinit var dbConnection: SQLiteConnection + private val context get() = InstrumentationRegistry.getInstrumentation().targetContext @BeforeTest fun setUpHelper() { - dbHelper = StreetCompleteSQLiteOpenHelper( - InstrumentationRegistry.getInstrumentation().targetContext, - DATABASE_NAME - ) - database = AndroidDatabase(dbHelper.writableDatabase) + val dbPath = context.getDatabasePath(DATABASE_NAME).path + dbConnection = BundledSQLiteDriver().open(dbPath) + database = StreetCompleteDatabase(dbConnection) } @Test fun databaseAvailable() { - assertNotNull(dbHelper.readableDatabase) + assertNotNull(database) } @AfterTest fun tearDownHelper() { - dbHelper.close() - InstrumentationRegistry.getInstrumentation().targetContext - .deleteDatabase(DATABASE_NAME) + dbConnection.close() + context.deleteDatabase(DATABASE_NAME) } companion object { diff --git a/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/util/ktx/SQLiteDatabaseKtTest.kt b/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/util/ktx/SQLiteDatabaseKtTest.kt index befdaa7c7c6..a7343159aa9 100644 --- a/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/util/ktx/SQLiteDatabaseKtTest.kt +++ b/app/src/androidInstrumentedTest/kotlin/de/westnordost/streetcomplete/util/ktx/SQLiteDatabaseKtTest.kt @@ -7,10 +7,10 @@ import kotlin.test.BeforeTest class SQLiteDatabaseKtTest : ApplicationDbTestCase() { @BeforeTest fun setUp() { - dbHelper.writableDatabase.execSQL("CREATE TABLE t (a int, b int)") + database.exec("CREATE TABLE t (a int, b int)") } @AfterTest fun tearDown() { - dbHelper.writableDatabase.execSQL("DROP TABLE t") + database.exec("DROP TABLE t") } } From 2444d0981e9c79597294156263c6cd151360c823 Mon Sep 17 00:00:00 2001 From: Flo Edelmann Date: Mon, 16 Mar 2026 23:56:16 +0100 Subject: [PATCH 15/16] Fix typo --- .../westnordost/streetcomplete/data/StreetCompleteDatabase.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt index 1d8e4362aeb..6cc0946a115 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt @@ -204,7 +204,7 @@ private fun SQLiteConnection.prepareQuery( limit: Int?, ): SQLiteStatement { require (having.isNullOrBlank() || !groupBy.isNullOrBlank()) { - "`having` clauses are only permitted when using a `groupBy clause" + "`having` clauses are only permitted when using a `groupBy` clause" } val columnNames = if (columns.isNullOrEmpty()) "*" else columns.joinToString(", ") From 98002051e131eb89733f783810cb6b86a2d25e40 Mon Sep 17 00:00:00 2001 From: Tobias Zwick Date: Mon, 30 Mar 2026 22:12:05 +0200 Subject: [PATCH 16/16] remove unused class --- .../de/westnordost/streetcomplete/util/ktx/ContentValues.kt | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 app/src/androidMain/kotlin/de/westnordost/streetcomplete/util/ktx/ContentValues.kt diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/util/ktx/ContentValues.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/util/ktx/ContentValues.kt deleted file mode 100644 index 916584991c0..00000000000 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/util/ktx/ContentValues.kt +++ /dev/null @@ -1,5 +0,0 @@ -package de.westnordost.streetcomplete.util.ktx - -import android.content.ContentValues - -operator fun ContentValues.plus(b: ContentValues) = ContentValues(this).also { it.putAll(b) }