diff --git a/common/src/desktopMain/kotlin/com/artemchep/keyguard/core/store/DatabaseSqlManagerInFileJvm.kt b/common/src/desktopMain/kotlin/com/artemchep/keyguard/core/store/DatabaseSqlManagerInFileJvm.kt index 7b8837192..403f06161 100644 --- a/common/src/desktopMain/kotlin/com/artemchep/keyguard/core/store/DatabaseSqlManagerInFileJvm.kt +++ b/common/src/desktopMain/kotlin/com/artemchep/keyguard/core/store/DatabaseSqlManagerInFileJvm.kt @@ -1,5 +1,6 @@ package com.artemchep.keyguard.core.store +import app.cash.sqldelight.TransacterImpl import app.cash.sqldelight.db.AfterVersion import app.cash.sqldelight.db.QueryResult import app.cash.sqldelight.db.SqlDriver @@ -68,32 +69,11 @@ class DatabaseSqlManagerInFileJvm( file = file, key = masterKey.byteArray, ) - - // Create or migrate the database schema. - val targetVersion = databaseSchema.version - val currentVersion = runCatching { - driver.getCurrentVersion() - }.getOrDefault(0L) - if (currentVersion > targetVersion) { + try { + migrateDatabaseSchema(driver, databaseSchema, *callbacks) + } catch (e: Throwable) { driver.close() - throw DatabaseSchemaDowngradeException( - currentVersion = currentVersion, - targetVersion = targetVersion, - ) - } - if (currentVersion == 0L) { - databaseSchema.create(driver) - } else if (targetVersion > currentVersion) { - databaseSchema.migrate( - driver, - currentVersion, - targetVersion, - *callbacks, - ) - } - // Bump the version to the current one. - if (currentVersion < targetVersion) { - driver.setCurrentVersion(targetVersion) + throw e } val database = databaseFactory(driver) @@ -147,28 +127,63 @@ class DatabaseSqlManagerInFileJvm( ) } - // Version - - private suspend fun SqlDriver.getCurrentVersion(): Long { - val queryResult = executeQuery( - identifier = null, - sql = "PRAGMA user_version;", - mapper = { cursor -> - val version = cursor.getLong(0) - requireNotNull(version) +} - QueryResult.Value(version) - }, - parameters = 0, - binders = null, - ) - return queryResult.await() +internal fun migrateDatabaseSchema( + driver: SqlDriver, + databaseSchema: SqlSchema>, + vararg callbacks: AfterVersion, +) { + // DDL is transactional in SQLite. Keeping schema changes and the + // user_version bump in one transaction prevents an interrupted upgrade + // from replaying already-created tables on the next run. + val transacter = object : TransacterImpl(driver) {} + transacter.transaction { + val targetVersion = databaseSchema.version + val currentVersion = runCatching { + driver.getCurrentVersion() + }.getOrDefault(0L) + if (currentVersion > targetVersion) { + throw DatabaseSchemaDowngradeException( + currentVersion = currentVersion, + targetVersion = targetVersion, + ) + } + if (currentVersion == 0L) { + databaseSchema.create(driver) + } else if (targetVersion > currentVersion) { + databaseSchema.migrate( + driver, + currentVersion, + targetVersion, + *callbacks, + ) + } + if (currentVersion < targetVersion) { + driver.setCurrentVersion(targetVersion) + } } +} - private suspend fun SqlDriver.setCurrentVersion(version: Long) { - execute(null, "PRAGMA user_version = $version;", 0, null) - .await() - } +private fun SqlDriver.getCurrentVersion(): Long { + val queryResult = executeQuery( + identifier = null, + sql = "PRAGMA user_version;", + mapper = { cursor -> + val version = cursor.getLong(0) + requireNotNull(version) + + QueryResult.Value(version) + }, + parameters = 0, + binders = null, + ) + return queryResult.value +} + +private fun SqlDriver.setCurrentVersion(version: Long) { + execute(null, "PRAGMA user_version = $version;", 0, null) + .value } internal class DatabaseSchemaDowngradeException( diff --git a/common/src/desktopTest/kotlin/com/artemchep/keyguard/core/store/DatabaseSqlManagerInFileJvmTest.kt b/common/src/desktopTest/kotlin/com/artemchep/keyguard/core/store/DatabaseSqlManagerInFileJvmTest.kt new file mode 100644 index 000000000..e6552dcfe --- /dev/null +++ b/common/src/desktopTest/kotlin/com/artemchep/keyguard/core/store/DatabaseSqlManagerInFileJvmTest.kt @@ -0,0 +1,63 @@ +package com.artemchep.keyguard.core.store + +import app.cash.sqldelight.db.AfterVersion +import app.cash.sqldelight.db.QueryResult +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.db.SqlSchema +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DatabaseSqlManagerInFileJvmTest { + @Test + fun `interrupted schema migration rolls back before retry`() { + var attempts = 0 + val schema = object : SqlSchema> { + override val version: Long = 2L + + override fun create(driver: SqlDriver) = QueryResult.Value(Unit) + + override fun migrate( + driver: SqlDriver, + oldVersion: Long, + newVersion: Long, + vararg callbacks: AfterVersion, + ): QueryResult.Value { + assertEquals(1L, oldVersion) + assertEquals(2L, newVersion) + driver.execute(null, "CREATE TABLE example (id INTEGER PRIMARY KEY)", 0, null) + attempts += 1 + if (attempts == 1) { + error("simulated interruption") + } + return QueryResult.Value(Unit) + } + } + val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + + try { + driver.execute(null, "PRAGMA user_version = 1", 0, null).value + val firstAttempt = runCatching { + migrateDatabaseSchema(driver, schema) + } + assertTrue(firstAttempt.isFailure) + migrateDatabaseSchema(driver, schema) + + assertEquals(2, attempts) + val count = driver.executeQuery( + identifier = null, + sql = "SELECT COUNT(*) FROM example", + mapper = { cursor -> + check(cursor.next().value) + QueryResult.Value(cursor.getLong(0)) + }, + parameters = 0, + binders = null, + ).value + assertEquals(0L, count) + } finally { + driver.close() + } + } +}