Skip to content

Commit 683cc69

Browse files
authored
Add in-memory database for tests (#273)
1 parent 2090d59 commit 683cc69

File tree

14 files changed

+230
-65
lines changed

14 files changed

+230
-65
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## 1.7.0 (unreleased)
4+
5+
- Add `PowerSyncDatabase.inMemory` to create an in-memory SQLite database with PowerSync.
6+
This may be useful for testing.
7+
38
## 1.6.1
49

510
* Fix `dlopen failed: library "libpowersync.so.so" not found` errors on Android.

core/src/androidMain/kotlin/com/powersync/DatabaseDriverFactory.android.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,5 @@ public fun BundledSQLiteDriver.addPowerSyncExtension() {
2626
@ExperimentalPowerSyncAPI
2727
@Throws(PowerSyncException::class)
2828
public actual fun resolvePowerSyncLoadableExtensionPath(): String? = "libpowersync.so"
29+
30+
internal actual fun openInMemoryConnection(): SQLiteConnection = BundledSQLiteDriver().also { it.addPowerSyncExtension() }.open(":memory:")

core/src/appleNonWatchOsMain/kotlin/com/powersync/DatabaseDriverFactory.appleNonWatchOs.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,5 @@ public actual class DatabaseDriverFactory {
2525
@ExperimentalPowerSyncAPI
2626
@Throws(PowerSyncException::class)
2727
public actual fun resolvePowerSyncLoadableExtensionPath(): String? = powerSyncExtensionPath
28+
29+
internal actual fun openInMemoryConnection(): SQLiteConnection = DatabaseDriverFactory().openConnection(":memory:", 0x02)
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package com.powersync.db
2+
3+
import app.cash.turbine.turbineScope
4+
import co.touchlab.kermit.ExperimentalKermitApi
5+
import co.touchlab.kermit.Logger
6+
import co.touchlab.kermit.Severity
7+
import co.touchlab.kermit.TestConfig
8+
import co.touchlab.kermit.TestLogWriter
9+
import com.powersync.PowerSyncDatabase
10+
import com.powersync.db.schema.Column
11+
import com.powersync.db.schema.Schema
12+
import com.powersync.db.schema.Table
13+
import io.kotest.matchers.collections.shouldHaveSize
14+
import io.kotest.matchers.shouldBe
15+
import kotlinx.coroutines.test.runTest
16+
import kotlin.test.Test
17+
18+
@OptIn(ExperimentalKermitApi::class)
19+
class InMemoryTest {
20+
private val logWriter =
21+
TestLogWriter(
22+
loggable = Severity.Debug,
23+
)
24+
25+
private val logger =
26+
Logger(
27+
TestConfig(
28+
minSeverity = Severity.Debug,
29+
logWriterList = listOf(logWriter),
30+
),
31+
)
32+
33+
@Test
34+
fun createsSchema() =
35+
runTest {
36+
val db = PowerSyncDatabase.Companion.inMemory(schema, this, logger)
37+
try {
38+
db.getAll("SELECT * FROM users") { } shouldHaveSize 0
39+
} finally {
40+
db.close()
41+
}
42+
}
43+
44+
@Test
45+
fun watch() =
46+
runTest {
47+
val db = PowerSyncDatabase.Companion.inMemory(schema, this, logger)
48+
try {
49+
turbineScope {
50+
val turbine =
51+
db.watch("SELECT name FROM users", mapper = { it.getString(0)!! }).testIn(this)
52+
53+
turbine.awaitItem() shouldBe listOf()
54+
55+
db.execute("INSERT INTO users (id, name) VALUES (uuid(), ?)", listOf("test user"))
56+
turbine.awaitItem() shouldBe listOf("test user")
57+
turbine.cancelAndIgnoreRemainingEvents()
58+
}
59+
} finally {
60+
db.close()
61+
}
62+
}
63+
64+
companion object {
65+
private val schema =
66+
Schema(
67+
Table(
68+
name = "users",
69+
columns =
70+
listOf(
71+
Column.Companion.text("name"),
72+
),
73+
),
74+
)
75+
}
76+
}

core/src/commonMain/kotlin/com/powersync/DatabaseDriverFactory.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ public expect class DatabaseDriverFactory {
1717
): SQLiteConnection
1818
}
1919

20+
internal expect fun openInMemoryConnection(): SQLiteConnection
21+
2022
/**
2123
* Resolves a path to the loadable PowerSync core extension library.
2224
*

core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ import com.powersync.db.Queries
1010
import com.powersync.db.crud.CrudBatch
1111
import com.powersync.db.crud.CrudTransaction
1212
import com.powersync.db.driver.SQLiteConnectionPool
13+
import com.powersync.db.driver.SingleConnectionPool
1314
import com.powersync.db.schema.Schema
1415
import com.powersync.sync.SyncOptions
1516
import com.powersync.sync.SyncStatus
1617
import com.powersync.sync.SyncStream
1718
import com.powersync.utils.JsonParam
19+
import com.powersync.utils.generateLogger
1820
import kotlinx.coroutines.CoroutineScope
1921
import kotlinx.coroutines.flow.Flow
2022
import kotlinx.coroutines.flow.firstOrNull
@@ -245,6 +247,29 @@ public interface PowerSyncDatabase : Queries {
245247
return openedWithGroup(pool, scope, schema, logger, group)
246248
}
247249

250+
/**
251+
* Creates an in-memory PowerSync database instance, useful for testing.
252+
*/
253+
@OptIn(ExperimentalPowerSyncAPI::class)
254+
public fun inMemory(
255+
schema: Schema,
256+
scope: CoroutineScope,
257+
logger: Logger? = null,
258+
): PowerSyncDatabase {
259+
val logger = generateLogger(logger)
260+
// Since this returns a fresh in-memory database every time, use a fresh group to avoid warnings about the
261+
// same database being opened multiple times.
262+
val collection = ActiveDatabaseGroup.GroupsCollection().referenceDatabase(logger, "test")
263+
264+
return openedWithGroup(
265+
SingleConnectionPool(openInMemoryConnection()),
266+
scope,
267+
schema,
268+
logger,
269+
collection,
270+
)
271+
}
272+
248273
@ExperimentalPowerSyncAPI
249274
internal fun openedWithGroup(
250275
pool: SQLiteConnectionPool,

core/src/commonMain/kotlin/com/powersync/db/driver/InternalConnectionPool.kt

Lines changed: 41 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -36,33 +36,7 @@ internal class InternalConnectionPool(
3636
readOnly = false,
3737
)
3838

39-
connection.execSQL("pragma journal_mode = WAL")
40-
connection.execSQL("pragma journal_size_limit = ${6 * 1024 * 1024}")
41-
connection.execSQL("pragma busy_timeout = 30000")
42-
connection.execSQL("pragma cache_size = ${50 * 1024}")
43-
44-
if (readOnly) {
45-
connection.execSQL("pragma query_only = TRUE")
46-
}
47-
48-
// Older versions of the SDK used to set up an empty schema and raise the user version to 1.
49-
// Keep doing that for consistency.
50-
if (!readOnly) {
51-
val version =
52-
connection.prepare("pragma user_version").use {
53-
require(it.step())
54-
if (it.isNull(0)) 0L else it.getLong(0)
55-
}
56-
if (version < 1L) {
57-
connection.execSQL("pragma user_version = 1")
58-
}
59-
60-
// Also install a commit, rollback and update hooks in the core extension to implement
61-
// the updates flow here (not all our driver implementations support hooks, so this is
62-
// a more reliable fallback).
63-
connection.execSQL("select powersync_update_hooks('install');")
64-
}
65-
39+
connection.setupDefaultPragmas(readOnly)
6640
return connection
6741
}
6842

@@ -75,13 +49,10 @@ internal class InternalConnectionPool(
7549
} finally {
7650
// When we've leased a write connection, we may have to update table update flows
7751
// after users ran their custom statements.
78-
writeConnection.prepare("SELECT powersync_update_hooks('get')").use {
79-
check(it.step())
80-
val updatedTables = JsonUtil.json.decodeFromString<Set<String>>(it.getText(0))
81-
if (updatedTables.isNotEmpty()) {
82-
scope.launch {
83-
tableUpdatesFlow.emit(updatedTables)
84-
}
52+
val updatedTables = writeConnection.readPendingUpdates()
53+
if (updatedTables.isNotEmpty()) {
54+
scope.launch {
55+
tableUpdatesFlow.emit(updatedTables)
8556
}
8657
}
8758
}
@@ -106,3 +77,39 @@ internal class InternalConnectionPool(
10677
readPool.close()
10778
}
10879
}
80+
81+
internal fun SQLiteConnection.setupDefaultPragmas(readOnly: Boolean) {
82+
execSQL("pragma journal_mode = WAL")
83+
execSQL("pragma journal_size_limit = ${6 * 1024 * 1024}")
84+
execSQL("pragma busy_timeout = 30000")
85+
execSQL("pragma cache_size = ${50 * 1024}")
86+
87+
if (readOnly) {
88+
execSQL("pragma query_only = TRUE")
89+
}
90+
91+
// Older versions of the SDK used to set up an empty schema and raise the user version to 1.
92+
// Keep doing that for consistency.
93+
if (!readOnly) {
94+
val version =
95+
prepare("pragma user_version").use {
96+
require(it.step())
97+
if (it.isNull(0)) 0L else it.getLong(0)
98+
}
99+
if (version < 1L) {
100+
execSQL("pragma user_version = 1")
101+
}
102+
103+
// Also install a commit, rollback and update hooks in the core extension to implement
104+
// the updates flow here (not all our driver implementations support hooks, so this is
105+
// a more reliable fallback).
106+
execSQL("select powersync_update_hooks('install');")
107+
}
108+
}
109+
110+
internal fun SQLiteConnection.readPendingUpdates(): Set<String> =
111+
prepare("SELECT powersync_update_hooks('get')").use {
112+
check(it.step())
113+
val updatedTables = JsonUtil.json.decodeFromString<Set<String>>(it.getText(0))
114+
updatedTables
115+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package com.powersync.db.driver
2+
3+
import androidx.sqlite.SQLiteConnection
4+
import com.powersync.ExperimentalPowerSyncAPI
5+
import kotlinx.coroutines.flow.MutableSharedFlow
6+
import kotlinx.coroutines.flow.SharedFlow
7+
import kotlinx.coroutines.sync.Mutex
8+
import kotlinx.coroutines.sync.withLock
9+
10+
/**
11+
* A [SQLiteConnectionPool] backed by a single database connection.
12+
*
13+
* This does not provide any concurrency, but is still a reasonable implementation to use for e.g. tests.
14+
*/
15+
@OptIn(ExperimentalPowerSyncAPI::class)
16+
internal class SingleConnectionPool(
17+
private val conn: SQLiteConnection,
18+
) : SQLiteConnectionPool {
19+
private val mutex: Mutex = Mutex()
20+
private var closed = false
21+
private val tableUpdatesFlow = MutableSharedFlow<Set<String>>(replay = 0)
22+
23+
init {
24+
conn.setupDefaultPragmas(false)
25+
}
26+
27+
override suspend fun <T> read(callback: suspend (SQLiteConnectionLease) -> T): T = write(callback)
28+
29+
override suspend fun <T> write(callback: suspend (SQLiteConnectionLease) -> T): T =
30+
mutex.withLock {
31+
check(!closed) { "Connection closed" }
32+
33+
try {
34+
callback(RawConnectionLease(conn))
35+
} finally {
36+
val updates = conn.readPendingUpdates()
37+
if (updates.isNotEmpty()) {
38+
tableUpdatesFlow.emit(updates)
39+
}
40+
}
41+
}
42+
43+
override suspend fun <R> withAllConnections(
44+
action: suspend (writer: SQLiteConnectionLease, readers: List<SQLiteConnectionLease>) -> R,
45+
) = write { writer ->
46+
action(writer, emptyList())
47+
Unit
48+
}
49+
50+
override val updates: SharedFlow<Set<String>>
51+
get() = tableUpdatesFlow
52+
53+
override suspend fun close() {
54+
mutex.withLock {
55+
conn.close()
56+
}
57+
}
58+
}

core/src/jvmMain/kotlin/com/powersync/DatabaseDriverFactory.jvm.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.powersync
22

33
import androidx.sqlite.SQLiteConnection
4+
import androidx.sqlite.driver.bundled.BundledSQLiteConnection
45
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
56
import com.powersync.db.runWrapped
67

@@ -25,3 +26,5 @@ private val powersyncExtension: String by lazy { extractLib("powersync") }
2526
@ExperimentalPowerSyncAPI
2627
@Throws(PowerSyncException::class)
2728
public actual fun resolvePowerSyncLoadableExtensionPath(): String? = runWrapped { powersyncExtension }
29+
30+
internal actual fun openInMemoryConnection(): SQLiteConnection = DatabaseDriverFactory().openConnection(":memory:", 0x02)

core/src/watchosMain/kotlin/com/powersync/DatabaseDriverFactory.watchos.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,5 @@ public actual fun resolvePowerSyncLoadableExtensionPath(): String? {
3838
didLoadExtension
3939
return null
4040
}
41+
42+
internal actual fun openInMemoryConnection(): SQLiteConnection = DatabaseDriverFactory().openConnection(":memory:", 0x02)

0 commit comments

Comments
 (0)