diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b42907a..207196f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 1.7.0 (unreleased) + +- Add `PowerSyncDatabase.inMemory` to create an in-memory SQLite database with PowerSync. + This may be useful for testing. + ## 1.6.1 * Fix `dlopen failed: library "libpowersync.so.so" not found` errors on Android. diff --git a/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt index a1221e29..8ee2b28f 100644 --- a/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt +++ b/core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt @@ -26,3 +26,5 @@ public fun BundledSQLiteDriver.addPowerSyncExtension() { @ExperimentalPowerSyncAPI @Throws(PowerSyncException::class) public actual fun resolvePowerSyncLoadableExtensionPath(): String? = "libpowersync.so" + +internal actual fun openInMemoryConnection(): SQLiteConnection = BundledSQLiteDriver().also { it.addPowerSyncExtension() }.open(":memory:") diff --git a/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt b/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt index dda195e8..54d2033b 100644 --- a/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt +++ b/core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt @@ -25,3 +25,5 @@ public actual class DatabaseDriverFactory { @ExperimentalPowerSyncAPI @Throws(PowerSyncException::class) public actual fun resolvePowerSyncLoadableExtensionPath(): String? = powerSyncExtensionPath + +internal actual fun openInMemoryConnection(): SQLiteConnection = DatabaseDriverFactory().openConnection(":memory:", 0x02) diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/db/InMemoryTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/db/InMemoryTest.kt new file mode 100644 index 00000000..ebc04960 --- /dev/null +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/db/InMemoryTest.kt @@ -0,0 +1,76 @@ +package com.powersync.db + +import app.cash.turbine.turbineScope +import co.touchlab.kermit.ExperimentalKermitApi +import co.touchlab.kermit.Logger +import co.touchlab.kermit.Severity +import co.touchlab.kermit.TestConfig +import co.touchlab.kermit.TestLogWriter +import com.powersync.PowerSyncDatabase +import com.powersync.db.schema.Column +import com.powersync.db.schema.Schema +import com.powersync.db.schema.Table +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.test.runTest +import kotlin.test.Test + +@OptIn(ExperimentalKermitApi::class) +class InMemoryTest { + private val logWriter = + TestLogWriter( + loggable = Severity.Debug, + ) + + private val logger = + Logger( + TestConfig( + minSeverity = Severity.Debug, + logWriterList = listOf(logWriter), + ), + ) + + @Test + fun createsSchema() = + runTest { + val db = PowerSyncDatabase.Companion.inMemory(schema, this, logger) + try { + db.getAll("SELECT * FROM users") { } shouldHaveSize 0 + } finally { + db.close() + } + } + + @Test + fun watch() = + runTest { + val db = PowerSyncDatabase.Companion.inMemory(schema, this, logger) + try { + turbineScope { + val turbine = + db.watch("SELECT name FROM users", mapper = { it.getString(0)!! }).testIn(this) + + turbine.awaitItem() shouldBe listOf() + + db.execute("INSERT INTO users (id, name) VALUES (uuid(), ?)", listOf("test user")) + turbine.awaitItem() shouldBe listOf("test user") + turbine.cancelAndIgnoreRemainingEvents() + } + } finally { + db.close() + } + } + + companion object { + private val schema = + Schema( + Table( + name = "users", + columns = + listOf( + Column.Companion.text("name"), + ), + ), + ) + } +} diff --git a/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt b/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt index eb2d67a9..18698c95 100644 --- a/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt +++ b/core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt @@ -17,6 +17,8 @@ public expect class DatabaseDriverFactory { ): SQLiteConnection } +internal expect fun openInMemoryConnection(): SQLiteConnection + /** * Resolves a path to the loadable PowerSync core extension library. * diff --git a/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt b/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt index de2b1399..22c65fa7 100644 --- a/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt +++ b/core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt @@ -10,10 +10,12 @@ import com.powersync.db.Queries import com.powersync.db.crud.CrudBatch import com.powersync.db.crud.CrudTransaction import com.powersync.db.driver.SQLiteConnectionPool +import com.powersync.db.driver.SingleConnectionPool import com.powersync.db.schema.Schema import com.powersync.sync.SyncOptions import com.powersync.sync.SyncStatus import com.powersync.utils.JsonParam +import com.powersync.utils.generateLogger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull @@ -233,6 +235,29 @@ public interface PowerSyncDatabase : Queries { return openedWithGroup(pool, scope, schema, logger, group) } + /** + * Creates an in-memory PowerSync database instance, useful for testing. + */ + @OptIn(ExperimentalPowerSyncAPI::class) + public fun inMemory( + schema: Schema, + scope: CoroutineScope, + logger: Logger? = null, + ): PowerSyncDatabase { + val logger = generateLogger(logger) + // Since this returns a fresh in-memory database every time, use a fresh group to avoid warnings about the + // same database being opened multiple times. + val collection = ActiveDatabaseGroup.GroupsCollection().referenceDatabase(logger, "test") + + return openedWithGroup( + SingleConnectionPool(openInMemoryConnection()), + scope, + schema, + logger, + collection, + ) + } + @ExperimentalPowerSyncAPI internal fun openedWithGroup( pool: SQLiteConnectionPool, diff --git a/core/src/commonMain/kotlin/com/powersync/db/driver/InternalConnectionPool.kt b/core/src/commonMain/kotlin/com/powersync/db/driver/InternalConnectionPool.kt index b5258f2a..682d5163 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/driver/InternalConnectionPool.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/driver/InternalConnectionPool.kt @@ -36,33 +36,7 @@ internal class InternalConnectionPool( readOnly = false, ) - connection.execSQL("pragma journal_mode = WAL") - connection.execSQL("pragma journal_size_limit = ${6 * 1024 * 1024}") - connection.execSQL("pragma busy_timeout = 30000") - connection.execSQL("pragma cache_size = ${50 * 1024}") - - if (readOnly) { - connection.execSQL("pragma query_only = TRUE") - } - - // Older versions of the SDK used to set up an empty schema and raise the user version to 1. - // Keep doing that for consistency. - if (!readOnly) { - val version = - connection.prepare("pragma user_version").use { - require(it.step()) - if (it.isNull(0)) 0L else it.getLong(0) - } - if (version < 1L) { - connection.execSQL("pragma user_version = 1") - } - - // Also install a commit, rollback and update hooks in the core extension to implement - // the updates flow here (not all our driver implementations support hooks, so this is - // a more reliable fallback). - connection.execSQL("select powersync_update_hooks('install');") - } - + connection.setupDefaultPragmas(readOnly) return connection } @@ -75,13 +49,10 @@ internal class InternalConnectionPool( } finally { // When we've leased a write connection, we may have to update table update flows // after users ran their custom statements. - writeConnection.prepare("SELECT powersync_update_hooks('get')").use { - check(it.step()) - val updatedTables = JsonUtil.json.decodeFromString>(it.getText(0)) - if (updatedTables.isNotEmpty()) { - scope.launch { - tableUpdatesFlow.emit(updatedTables) - } + val updatedTables = writeConnection.readPendingUpdates() + if (updatedTables.isNotEmpty()) { + scope.launch { + tableUpdatesFlow.emit(updatedTables) } } } @@ -106,3 +77,39 @@ internal class InternalConnectionPool( readPool.close() } } + +internal fun SQLiteConnection.setupDefaultPragmas(readOnly: Boolean) { + execSQL("pragma journal_mode = WAL") + execSQL("pragma journal_size_limit = ${6 * 1024 * 1024}") + execSQL("pragma busy_timeout = 30000") + execSQL("pragma cache_size = ${50 * 1024}") + + if (readOnly) { + execSQL("pragma query_only = TRUE") + } + + // Older versions of the SDK used to set up an empty schema and raise the user version to 1. + // Keep doing that for consistency. + if (!readOnly) { + val version = + prepare("pragma user_version").use { + require(it.step()) + if (it.isNull(0)) 0L else it.getLong(0) + } + if (version < 1L) { + execSQL("pragma user_version = 1") + } + + // Also install a commit, rollback and update hooks in the core extension to implement + // the updates flow here (not all our driver implementations support hooks, so this is + // a more reliable fallback). + execSQL("select powersync_update_hooks('install');") + } +} + +internal fun SQLiteConnection.readPendingUpdates(): Set = + prepare("SELECT powersync_update_hooks('get')").use { + check(it.step()) + val updatedTables = JsonUtil.json.decodeFromString>(it.getText(0)) + updatedTables + } diff --git a/core/src/commonMain/kotlin/com/powersync/db/driver/SingleConnectionPool.kt b/core/src/commonMain/kotlin/com/powersync/db/driver/SingleConnectionPool.kt new file mode 100644 index 00000000..41337f96 --- /dev/null +++ b/core/src/commonMain/kotlin/com/powersync/db/driver/SingleConnectionPool.kt @@ -0,0 +1,58 @@ +package com.powersync.db.driver + +import androidx.sqlite.SQLiteConnection +import com.powersync.ExperimentalPowerSyncAPI +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * A [SQLiteConnectionPool] backed by a single database connection. + * + * This does not provide any concurrency, but is still a reasonable implementation to use for e.g. tests. + */ +@OptIn(ExperimentalPowerSyncAPI::class) +internal class SingleConnectionPool( + private val conn: SQLiteConnection, +) : SQLiteConnectionPool { + private val mutex: Mutex = Mutex() + private var closed = false + private val tableUpdatesFlow = MutableSharedFlow>(replay = 0) + + init { + conn.setupDefaultPragmas(false) + } + + override suspend fun read(callback: suspend (SQLiteConnectionLease) -> T): T = write(callback) + + override suspend fun write(callback: suspend (SQLiteConnectionLease) -> T): T = + mutex.withLock { + check(!closed) { "Connection closed" } + + try { + callback(RawConnectionLease(conn)) + } finally { + val updates = conn.readPendingUpdates() + if (updates.isNotEmpty()) { + tableUpdatesFlow.emit(updates) + } + } + } + + override suspend fun withAllConnections( + action: suspend (writer: SQLiteConnectionLease, readers: List) -> R, + ) = write { writer -> + action(writer, emptyList()) + Unit + } + + override val updates: SharedFlow> + get() = tableUpdatesFlow + + override suspend fun close() { + mutex.withLock { + conn.close() + } + } +} diff --git a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt index 68a9bc47..3467e699 100644 --- a/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt +++ b/core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt @@ -1,6 +1,7 @@ package com.powersync import androidx.sqlite.SQLiteConnection +import androidx.sqlite.driver.bundled.BundledSQLiteConnection import androidx.sqlite.driver.bundled.BundledSQLiteDriver import com.powersync.db.runWrapped @@ -25,3 +26,5 @@ private val powersyncExtension: String by lazy { extractLib("powersync") } @ExperimentalPowerSyncAPI @Throws(PowerSyncException::class) public actual fun resolvePowerSyncLoadableExtensionPath(): String? = runWrapped { powersyncExtension } + +internal actual fun openInMemoryConnection(): SQLiteConnection = DatabaseDriverFactory().openConnection(":memory:", 0x02) diff --git a/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt b/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt index 10f73537..29bd81ee 100644 --- a/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt +++ b/core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt @@ -38,3 +38,5 @@ public actual fun resolvePowerSyncLoadableExtensionPath(): String? { didLoadExtension return null } + +internal actual fun openInMemoryConnection(): SQLiteConnection = DatabaseDriverFactory().openConnection(":memory:", 0x02) diff --git a/integrations/sqldelight/build.gradle.kts b/integrations/sqldelight/build.gradle.kts index 77007786..d9eb1aba 100644 --- a/integrations/sqldelight/build.gradle.kts +++ b/integrations/sqldelight/build.gradle.kts @@ -22,21 +22,21 @@ kotlin { implementation(libs.kotlinx.coroutines.core) } - commonTest.dependencies { - // Separate project because SQLDelight can't generate code in test source sets. - implementation(projects.integrations.sqldelightTestDatabase) + val commonIntegrationTest by creating { + dependsOn(commonTest.get()) - implementation(libs.kotlin.test) - implementation(libs.kotlinx.io) - implementation(libs.test.turbine) - implementation(libs.test.coroutines) - implementation(libs.test.kotest.assertions) + dependencies { + // Separate project because SQLDelight can't generate code in test source sets. + implementation(projects.integrations.sqldelightTestDatabase) - implementation(libs.sqldelight.coroutines) - } + implementation(libs.kotlin.test) + implementation(libs.kotlinx.io) + implementation(libs.test.turbine) + implementation(libs.test.coroutines) + implementation(libs.test.kotest.assertions) - val commonIntegrationTest by creating { - dependsOn(commonTest.get()) + implementation(libs.sqldelight.coroutines) + } } // The PowerSync SDK links the core extension, so we can just run tests as-is. diff --git a/integrations/sqldelight/src/appleTest/kotlin/com/powersync/integrations/sqldelight/SqlDelightTest.apple.kt b/integrations/sqldelight/src/appleTest/kotlin/com/powersync/integrations/sqldelight/SqlDelightTest.apple.kt deleted file mode 100644 index 8cc9d44d..00000000 --- a/integrations/sqldelight/src/appleTest/kotlin/com/powersync/integrations/sqldelight/SqlDelightTest.apple.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.powersync.integrations.sqldelight - -import com.powersync.DatabaseDriverFactory - -actual fun databaseDriverFactory(): DatabaseDriverFactory = DatabaseDriverFactory() diff --git a/integrations/sqldelight/src/commonIntegrationTest/kotlin/com/powersync/integrations/sqldelight/SqlDelightTest.kt b/integrations/sqldelight/src/commonIntegrationTest/kotlin/com/powersync/integrations/sqldelight/SqlDelightTest.kt index f3edf259..2cdbf85a 100644 --- a/integrations/sqldelight/src/commonIntegrationTest/kotlin/com/powersync/integrations/sqldelight/SqlDelightTest.kt +++ b/integrations/sqldelight/src/commonIntegrationTest/kotlin/com/powersync/integrations/sqldelight/SqlDelightTest.kt @@ -144,12 +144,9 @@ class SqlDelightTest { private fun databaseTest(body: suspend TestScope.(PowerSyncDatabase) -> Unit) { runTest { - val allowedChars = ('A'..'Z') + ('a'..'z') + ('0'..'9') - val suffix = CharArray(8) { allowedChars.random() }.concatToString() - val db = - PowerSyncDatabase( - databaseDriverFactory(), + PowerSyncDatabase.inMemory( + scope = this, schema = Schema( Table( @@ -160,13 +157,9 @@ private fun databaseTest(body: suspend TestScope.(PowerSyncDatabase) -> Unit) { ), ), ), - dbFilename = "db-$suffix", - dbDirectory = SystemTemporaryDirectory.toString(), ) body(db) db.close() } } - -expect fun databaseDriverFactory(): DatabaseDriverFactory diff --git a/integrations/sqldelight/src/jvmTest/kotlin/com/powersync/integrations/sqldelight/SqlDelightTest.jvm.kt b/integrations/sqldelight/src/jvmTest/kotlin/com/powersync/integrations/sqldelight/SqlDelightTest.jvm.kt deleted file mode 100644 index 8cc9d44d..00000000 --- a/integrations/sqldelight/src/jvmTest/kotlin/com/powersync/integrations/sqldelight/SqlDelightTest.jvm.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.powersync.integrations.sqldelight - -import com.powersync.DatabaseDriverFactory - -actual fun databaseDriverFactory(): DatabaseDriverFactory = DatabaseDriverFactory()