Skip to content

Commit 522f4c6

Browse files
committed
Add raw connection API
1 parent 435c7c5 commit 522f4c6

File tree

12 files changed

+163
-86
lines changed

12 files changed

+163
-86
lines changed

core/build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ kotlin {
183183
languageSettings {
184184
optIn("kotlinx.cinterop.ExperimentalForeignApi")
185185
optIn("kotlin.time.ExperimentalTime")
186+
optIn("kotlin.experimental.ExperimentalObjCRefinement")
186187
}
187188
}
188189

@@ -203,6 +204,8 @@ kotlin {
203204
}
204205

205206
dependencies {
207+
api(libs.androidx.sqlite)
208+
206209
implementation(libs.uuid)
207210
implementation(libs.kotlin.stdlib)
208211
implementation(libs.ktor.client.contentnegotiation)

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ public actual class DatabaseDriverFactory(
1515
dbFilename: String,
1616
dbDirectory: String?,
1717
readOnly: Boolean,
18-
listener: ConnectionListener?
18+
listener: ConnectionListener?,
1919
): SQLiteConnection {
2020
val dbPath =
2121
if (dbDirectory != null) {

core/src/appleMain/kotlin/com/powersync/DatabaseDriverFactory.apple.kt

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ public actual class DatabaseDriverFactory {
3333
dbFilename: String,
3434
dbDirectory: String?,
3535
readOnly: Boolean,
36-
listener: ConnectionListener?
36+
listener: ConnectionListener?,
3737
): SQLiteConnection {
3838
val directory = dbDirectory ?: defaultDatabaseDirectory()
3939
val path = Path(directory, dbFilename).toString()
@@ -61,15 +61,16 @@ public actual class DatabaseDriverFactory {
6161
@OptIn(UnsafeNumber::class)
6262
private fun defaultDatabaseDirectory(search: String = "databases"): String {
6363
// This needs to be compatible with https://github.com/touchlab/SQLiter/blob/a37bbe7e9c65e6a5a94c5bfcaccdaae55ad2bac9/sqliter-driver/src/appleMain/kotlin/co/touchlab/sqliter/DatabaseFileContext.kt#L36-L51
64-
val paths = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, true);
65-
val documentsDirectory = paths[0] as String;
64+
val paths = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, true)
65+
val documentsDirectory = paths[0] as String
6666

6767
val databaseDirectory = "$documentsDirectory/$search"
6868

6969
val fileManager = NSFileManager.defaultManager()
7070

71-
if (!fileManager.fileExistsAtPath(databaseDirectory))
72-
fileManager.createDirectoryAtPath(databaseDirectory, true, null, null); //Create folder
71+
if (!fileManager.fileExistsAtPath(databaseDirectory)) {
72+
fileManager.createDirectoryAtPath(databaseDirectory, true, null, null)
73+
}; // Create folder
7374

7475
return databaseDirectory
7576
}

core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt

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

33
import app.cash.turbine.test
4+
import androidx.sqlite.SQLiteConnection
5+
import androidx.sqlite.execSQL
46
import app.cash.turbine.turbineScope
57
import co.touchlab.kermit.ExperimentalKermitApi
68
import com.powersync.db.ActiveDatabaseGroup
@@ -16,6 +18,7 @@ import io.kotest.assertions.throwables.shouldThrow
1618
import io.kotest.matchers.collections.shouldHaveSize
1719
import io.kotest.matchers.shouldBe
1820
import io.kotest.matchers.string.shouldContain
21+
import io.kotest.matchers.throwable.shouldHaveMessage
1922
import kotlinx.coroutines.CompletableDeferred
2023
import kotlinx.coroutines.Dispatchers
2124
import kotlinx.coroutines.async
@@ -495,4 +498,38 @@ class DatabaseTest {
495498

496499
database.getCrudBatch() shouldBe null
497500
}
501+
502+
@Test
503+
fun testRawConnection() =
504+
databaseTest {
505+
database.execute(
506+
"INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)",
507+
listOf("a", "[email protected]"),
508+
)
509+
var capturedConnection: SQLiteConnection? = null
510+
511+
database.readLock {
512+
it.rawConnection.prepare("SELECT * FROM users").use { stmt ->
513+
stmt.step() shouldBe true
514+
stmt.getText(1) shouldBe "a"
515+
stmt.getText(2) shouldBe "[email protected]"
516+
}
517+
518+
capturedConnection = it.rawConnection
519+
}
520+
521+
// When we exit readLock, the connection should no longer be usable
522+
shouldThrow<IllegalStateException> { capturedConnection!!.execSQL("DELETE FROM users") } shouldHaveMessage
523+
"Connection lease already closed"
524+
525+
capturedConnection = null
526+
database.writeLock {
527+
it.rawConnection.execSQL("DELETE FROM users")
528+
capturedConnection = it.rawConnection
529+
}
530+
531+
// Same thing for writes
532+
shouldThrow<IllegalStateException> { capturedConnection!!.prepare("SELECT * FROM users") } shouldHaveMessage
533+
"Connection lease already closed"
534+
}
498535
}

core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.powersync.db
22

3+
import androidx.sqlite.SQLiteConnection
34
import co.touchlab.kermit.Logger
45
import com.powersync.DatabaseDriverFactory
56
import com.powersync.PowerSyncDatabase
@@ -43,7 +44,6 @@ import kotlinx.coroutines.sync.withLock
4344
import kotlinx.datetime.LocalDateTime
4445
import kotlinx.datetime.TimeZone
4546
import kotlinx.datetime.toInstant
46-
import kotlin.math.log
4747
import kotlin.time.Duration.Companion.milliseconds
4848
import kotlin.time.Instant
4949

core/src/commonMain/kotlin/com/powersync/db/SqlCursor.kt

Lines changed: 25 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -31,62 +31,54 @@ private inline fun <T> SqlCursor.getColumnValue(
3131
return getValue(index) ?: throw IllegalArgumentException("Null value found for column '$name'")
3232
}
3333

34-
internal class StatementBasedCursor(private val stmt: SQLiteStatement): SqlCursor {
35-
override fun getBoolean(index: Int): Boolean? {
36-
return getNullable(index) { index -> stmt.getLong(index) != 0L }
37-
}
34+
internal class StatementBasedCursor(
35+
private val stmt: SQLiteStatement,
36+
) : SqlCursor {
37+
override fun getBoolean(index: Int): Boolean? = getNullable(index) { index -> stmt.getLong(index) != 0L }
3838

39-
override fun getBytes(index: Int): ByteArray? {
40-
return getNullable(index, SQLiteStatement::getBlob)
41-
}
39+
override fun getBytes(index: Int): ByteArray? = getNullable(index, SQLiteStatement::getBlob)
4240

43-
override fun getDouble(index: Int): Double? {
44-
return getNullable(index, SQLiteStatement::getDouble)
45-
}
41+
override fun getDouble(index: Int): Double? = getNullable(index, SQLiteStatement::getDouble)
4642

47-
override fun getLong(index: Int): Long? {
48-
return getNullable(index, SQLiteStatement::getLong)
49-
}
43+
override fun getLong(index: Int): Long? = getNullable(index, SQLiteStatement::getLong)
5044

51-
override fun getString(index: Int): String? {
52-
return getNullable(index, SQLiteStatement::getText)
53-
}
45+
override fun getString(index: Int): String? = getNullable(index, SQLiteStatement::getText)
5446

55-
private inline fun <T> getNullable(index: Int, read: SQLiteStatement.(Int) -> T): T? {
56-
return if (stmt.isNull(index)) {
47+
private inline fun <T> getNullable(
48+
index: Int,
49+
read: SQLiteStatement.(Int) -> T,
50+
): T? =
51+
if (stmt.isNull(index)) {
5752
null
5853
} else {
5954
stmt.read(index)
6055
}
61-
}
6256

63-
override fun columnName(index: Int): String? {
64-
return stmt.getColumnName(index)
65-
}
57+
override fun columnName(index: Int): String? = stmt.getColumnName(index)
6658

6759
override val columnCount: Int
6860
get() = stmt.getColumnCount()
6961

7062
override val columnNames: Map<String, Int> by lazy {
7163
buildMap {
7264
stmt.getColumnNames().forEachIndexed { index, key ->
73-
val finalKey = if (containsKey(key)) {
74-
var index = 1
75-
val basicKey = "$key&JOIN"
76-
var finalKey = basicKey + index
77-
while (containsKey(finalKey)) {
78-
finalKey = basicKey + ++index
65+
val finalKey =
66+
if (containsKey(key)) {
67+
var index = 1
68+
val basicKey = "$key&JOIN"
69+
var finalKey = basicKey + index
70+
while (containsKey(finalKey)) {
71+
finalKey = basicKey + ++index
72+
}
73+
finalKey
74+
} else {
75+
key
7976
}
80-
finalKey
81-
} else {
82-
key
83-
}
8477

8578
put(finalKey, index)
8679
}
8780
}
8881
}
89-
9082
}
9183

9284
private inline fun <T> SqlCursor.getColumnValueOptional(

core/src/commonMain/kotlin/com/powersync/db/internal/ConnectionContext.kt

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,12 @@ import androidx.sqlite.SQLiteStatement
55
import com.powersync.PowerSyncException
66
import com.powersync.db.SqlCursor
77
import com.powersync.db.StatementBasedCursor
8+
import kotlin.native.HiddenFromObjC
89

910
public interface ConnectionContext {
11+
@HiddenFromObjC
12+
public val rawConnection: SQLiteConnection
13+
1014
@Throws(PowerSyncException::class)
1115
public fun execute(
1216
sql: String,
@@ -35,10 +39,12 @@ public interface ConnectionContext {
3539
): RowType
3640
}
3741

38-
internal class ConnectionContextImplementation(val connection: SQLiteConnection): ConnectionContext {
42+
internal class ConnectionContextImplementation(
43+
override val rawConnection: SQLiteConnection,
44+
) : ConnectionContext {
3945
override fun execute(
4046
sql: String,
41-
parameters: List<Any?>?
47+
parameters: List<Any?>?,
4248
): Long {
4349
withStatement(sql, parameters) {
4450
while (it.step()) {
@@ -53,46 +59,47 @@ internal class ConnectionContextImplementation(val connection: SQLiteConnection)
5359
override fun <RowType : Any> getOptional(
5460
sql: String,
5561
parameters: List<Any?>?,
56-
mapper: (SqlCursor) -> RowType
57-
): RowType? {
58-
return withStatement(sql, parameters) { stmt ->
62+
mapper: (SqlCursor) -> RowType,
63+
): RowType? =
64+
withStatement(sql, parameters) { stmt ->
5965
if (stmt.step()) {
6066
mapper(StatementBasedCursor(stmt))
6167
} else {
6268
null
6369
}
6470
}
65-
}
6671

6772
override fun <RowType : Any> getAll(
6873
sql: String,
6974
parameters: List<Any?>?,
70-
mapper: (SqlCursor) -> RowType
71-
): List<RowType> {
72-
return withStatement(sql, parameters) { stmt ->
75+
mapper: (SqlCursor) -> RowType,
76+
): List<RowType> =
77+
withStatement(sql, parameters) { stmt ->
7378
buildList {
7479
val cursor = StatementBasedCursor(stmt)
7580
while (stmt.step()) {
7681
add(mapper(cursor))
7782
}
7883
}
7984
}
80-
}
8185

8286
override fun <RowType : Any> get(
8387
sql: String,
8488
parameters: List<Any?>?,
85-
mapper: (SqlCursor) -> RowType
86-
): RowType {
87-
return getOptional(sql, parameters, mapper) ?: throw PowerSyncException("get() called with query that returned no rows", null)
88-
}
89+
mapper: (SqlCursor) -> RowType,
90+
): RowType = getOptional(sql, parameters, mapper) ?: throw PowerSyncException("get() called with query that returned no rows", null)
8991

90-
private inline fun <T> withStatement(sql: String, parameters: List<Any?>?, block: (SQLiteStatement) -> T): T {
91-
return prepareStmt(sql, parameters).use(block)
92-
}
92+
private inline fun <T> withStatement(
93+
sql: String,
94+
parameters: List<Any?>?,
95+
block: (SQLiteStatement) -> T,
96+
): T = prepareStmt(sql, parameters).use(block)
9397

94-
private fun prepareStmt(sql: String, parameters: List<Any?>?): SQLiteStatement {
95-
return connection.prepare(sql).apply {
98+
private fun prepareStmt(
99+
sql: String,
100+
parameters: List<Any?>?,
101+
): SQLiteStatement =
102+
rawConnection.prepare(sql).apply {
96103
try {
97104
parameters?.forEachIndexed { i, parameter ->
98105
// SQLite parameters are 1-indexed
@@ -117,5 +124,4 @@ internal class ConnectionContextImplementation(val connection: SQLiteConnection)
117124
throw e
118125
}
119126
}
120-
}
121127
}

core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,15 @@ internal class InternalDatabaseImpl(
4747
private val dbContext = Dispatchers.IO
4848

4949
private fun newConnection(readOnly: Boolean): SQLiteConnection {
50-
val connection = factory.openDatabase(
51-
dbFilename = dbFilename,
52-
dbDirectory = dbDirectory,
53-
readOnly = false,
54-
// We don't need a listener on read-only connections since we don't expect any update
55-
// hooks here.
56-
listener = if (readOnly) null else updates,
57-
)
50+
val connection =
51+
factory.openDatabase(
52+
dbFilename = dbFilename,
53+
dbDirectory = dbDirectory,
54+
readOnly = false,
55+
// We don't need a listener on read-only connections since we don't expect any update
56+
// hooks here.
57+
listener = if (readOnly) null else updates,
58+
)
5859

5960
connection.execSQL("pragma journal_mode = WAL")
6061
connection.execSQL("pragma journal_size_limit = ${6 * 1024 * 1024}")
@@ -68,10 +69,11 @@ internal class InternalDatabaseImpl(
6869
// Older versions of the SDK used to set up an empty schema and raise the user version to 1.
6970
// Keep doing that for consistency.
7071
if (!readOnly) {
71-
val version = connection.prepare("pragma user_version").use {
72-
require(it.step())
73-
if (it.isNull(0)) 0L else it.getLong(0)
74-
}
72+
val version =
73+
connection.prepare("pragma user_version").use {
74+
require(it.step())
75+
if (it.isNull(0)) 0L else it.getLong(0)
76+
}
7577
if (version < 1L) {
7678
connection.execSQL("pragma user_version = 1")
7779
}
@@ -212,7 +214,8 @@ internal class InternalDatabaseImpl(
212214
runWrapped {
213215
readPool.withConnection {
214216
catchSwiftExceptions {
215-
callback(it)
217+
val lease = RawConnectionLease(it)
218+
callback(lease).also { lease.completed = true }
216219
}
217220
}
218221
}
@@ -235,14 +238,17 @@ internal class InternalDatabaseImpl(
235238
private suspend fun <R> internalWriteLock(callback: (SQLiteConnection) -> R): R =
236239
withContext(dbContext) {
237240
writeLockMutex.withLock {
241+
val lease = RawConnectionLease(writeConnection)
242+
238243
runWrapped {
239244
catchSwiftExceptions {
240-
callback(writeConnection)
245+
callback(lease)
241246
}
242247
}.also {
243248
// Trigger watched queries
244249
// Fire updates inside the write lock
245250
updates.fireTableUpdates()
251+
lease.completed = true
246252
}
247253
}
248254
}
@@ -267,7 +273,7 @@ internal class InternalDatabaseImpl(
267273

268274
// Unfortunately Errors can't be thrown from Swift SDK callbacks.
269275
// These are currently returned and should be thrown here.
270-
private fun <R> catchSwiftExceptions(action: () -> R): R {
276+
private inline fun <R> catchSwiftExceptions(action: () -> R): R {
271277
val result = action()
272278

273279
if (result is PowerSyncException) {

0 commit comments

Comments
 (0)