diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a45bdb784fd..802722f689f 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.6.2") + implementation("androidx.sqlite:sqlite-bundled:2.6.2") + // 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/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") } } 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/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) } 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..6cc0946a115 --- /dev/null +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/StreetCompleteDatabase.kt @@ -0,0 +1,301 @@ +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 +import kotlinx.atomicfu.locks.ReentrantLock +import kotlinx.atomicfu.locks.withLock + +class StreetCompleteDatabase(private val databaseConnection: SQLiteConnection) : Database { + private val lock = ReentrantLock() + 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() + } + val newVersion = DatabaseInitializer.DB_VERSION + + if (oldVersion < newVersion) { + if (oldVersion == 0) { + DatabaseInitializer.onCreate(this) + } else { + DatabaseInitializer.onUpgrade(this, oldVersion, newVersion) + } + databaseConnection.execSQL("PRAGMA user_version = $newVersion") + } + } + + override fun exec(sql: String, args: Array?): Unit = lock.withLock { + databaseConnection.prepare(sql).use { statement -> + statement.bindAll(args) + statement.step() + } + } + + override fun rawQuery( + sql: String, + args: Array?, + transform: (CursorPosition) -> T, + ): List = lock.withLock { + 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? = 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, + columns: Array?, + where: String?, + args: Array?, + groupBy: String?, + having: String?, + orderBy: String?, + limit: Int?, + distinct: Boolean, + transform: (CursorPosition) -> T, + ): 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 = 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 = lock.withLock { + 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() + } + } + result + } + } + + override fun update( + table: String, + values: Collection>, + where: String?, + args: Array?, + conflictAlgorithm: ConflictAlgorithm?, + ): Int = lock.withLock { + databaseConnection.prepareUpdate(table, values, where, conflictAlgorithm).use { statement -> + val valueArgs = values.map { it.second } + val allArgs = (valueArgs + args.orEmpty()).toTypedArray() + statement.bindAll(allArgs) + statement.toSequence { }.count() + } + } + + 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 = lock.withLock { + val isOutermost = transactionDepth == 0 + transactionDepth++ + try { + if (isOutermost) { + databaseConnection.execSQL("BEGIN IMMEDIATE TRANSACTION") + } + val result = block() + if (isOutermost) { + databaseConnection.execSQL("END TRANSACTION") + } + return result + } catch (t: Throwable) { + if (isOutermost) { + databaseConnection.execSQL("ROLLBACK TRANSACTION") + } + throw t + } finally { + transactionDepth-- + } + } +} + +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 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) { + 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(", ") + 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.toString()) +} + +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.joinToString(", ") { it.first + "=?" } + val whereClause = if (where.isNullOrBlank()) "" else "WHERE $where" + val sql = "UPDATE $conflictSql $table SET $placeholders $whereClause RETURNING 1" + + 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 RETURNING 1" + + 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::class.simpleName + 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 = 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) +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)