diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ed23c2fd..9c5cde47 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,13 +16,23 @@ jobs: matrix: include: - os: macos-latest - targets: iosSimulatorArm64Test macosArm64Test watchosSimulatorArm64Test tvosSimulatorArm64Test jvmTest + name: macos-watchos-tvos + targets: watchosSimulatorArm64Test tvosSimulatorArm64Test + - os: macos-latest + name: mac-ios-macos + targets: iosSimulatorArm64Test macosArm64Test + - os: macos-latest + name: mac-jvm + targets: jvmTest - os: ubuntu-latest + name: ubuntu targets: testDebugUnitTest testReleaseUnitTest jvmTest lintKotlin - os: windows-latest + name: windows targets: jvmTest runs-on: ${{ matrix.os }} - timeout-minutes: 30 + name: Test ${{ matrix.name }} + timeout-minutes: 20 steps: - uses: actions/checkout@v4 @@ -60,7 +70,7 @@ jobs: if: failure() uses: actions/upload-artifact@v4 with: - name: report-for-${{ matrix.os }} + name: report-for-${{ matrix.name }} path: | **/build/reports/ **/build/test-results/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a03a821..917cd390 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ `androidx.sqlite` that can be used to step through statements in a custom way. * Fix an issue where `watch()` would run queries more often than intended. * Add an integration for the Room database library ([readme](integrations/room/README.md)). +* Add the `com.powersync:integration-sqldelight` module providing a SQLDelight driver based on open + PowerSync instances. See [the readme](integrations/sqldelight/README.md) for details. ## 1.5.1 diff --git a/README.md b/README.md index 7944a088..56c60fcc 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ and API documentation [here](https://powersync-ja.github.io/powersync-kotlin/). - [integrations](./integrations/) - [room](./integrations/room/README.md): Allows using the [Room database library](https://developer.android.com/jetpack/androidx/releases/room) with PowerSync, making it easier to run typed queries on the database. + - [sqldelight](./integrations/sqldelight/README.md): Allows using [SQLDelight](https://sqldelight.github.io/sqldelight) + with PowerSync, also enabling typed statements on the database. ## Demo Apps / Example Projects diff --git a/compose/build.gradle.kts b/compose/build.gradle.kts index 85af4b94..428f7b0c 100644 --- a/compose/build.gradle.kts +++ b/compose/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.compose.compiler) alias(libs.plugins.kotlinter) id("com.powersync.plugins.sonatype") + id("com.powersync.plugins.sharedbuild") id("dokka-convention") } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8d18a138..bc938da3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,6 +33,7 @@ compose-lifecycle = "2.9.2" androidxSqlite = "2.6.0-rc02" androidxSplashscreen = "1.0.1" room = "2.8.0-rc02" +sqldelight = "2.1.0" # plugins android-gradle-plugin = "8.12.1" @@ -103,6 +104,9 @@ androidx-sqlite-sqlite = { module = "androidx.sqlite:sqlite", version.ref = "and androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "androidxSqlite" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } +sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" } +sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" } +sqldelight-dialect-sqlite38 = { module = "app.cash.sqldelight:sqlite-3-38-dialect", version.ref = "sqldelight" } # Sample - Android androidx-core = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core" } @@ -151,3 +155,4 @@ kotlin-atomicfu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "atomic buildKonfig = { id = "com.codingfeline.buildkonfig", version.ref = "buildKonfig" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } androidx-room = { id = "androidx.room", version.ref = "room" } +sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } diff --git a/integrations/sqldelight-test-database/build.gradle.kts b/integrations/sqldelight-test-database/build.gradle.kts new file mode 100644 index 00000000..4359a859 --- /dev/null +++ b/integrations/sqldelight-test-database/build.gradle.kts @@ -0,0 +1,46 @@ +import com.powersync.plugins.utils.powersyncTargets +import org.jmailen.gradle.kotlinter.tasks.FormatTask +import org.jmailen.gradle.kotlinter.tasks.LintTask + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.kotlinter) + alias(libs.plugins.sqldelight) + id("com.powersync.plugins.sharedbuild") +} + +kotlin { + // We don't test on Android devices, JVM tests are enough for the SQLDelight test package since + // it doesn't contain Android-specific code. + powersyncTargets(android = false) + + explicitApi() + applyDefaultHierarchyTemplate() + + sourceSets { + commonMain.dependencies { + api(libs.sqldelight.runtime) + } + } +} + +sqldelight { + databases { + linkSqlite.set(false) + + create("TestDatabase") { + packageName.set("com.powersync.integrations.sqldelight") + generateAsync.set(true) + deriveSchemaFromMigrations.set(false) + dialect(libs.sqldelight.dialect.sqlite38) + } + } +} + +tasks.withType { + exclude { it.file.path.contains("build/generated") } +} + +tasks.withType { + exclude { it.file.path.contains("build/generated") } +} diff --git a/integrations/sqldelight-test-database/src/commonMain/sqldelight/com/powersync/integrations/sqldelight/todos.sq b/integrations/sqldelight-test-database/src/commonMain/sqldelight/com/powersync/integrations/sqldelight/todos.sq new file mode 100644 index 00000000..683428da --- /dev/null +++ b/integrations/sqldelight-test-database/src/commonMain/sqldelight/com/powersync/integrations/sqldelight/todos.sq @@ -0,0 +1,14 @@ +CREATE TABLE todos ( + id TEXT NOT NULL DEFAULT '', + title TEXT, + content TEXT +); + +all: +SELECT * FROM todos; + +create: +INSERT INTO todos (id, title, content) VALUES (uuid(), ?, ?); + +update: +UPDATE todos SET content = content || title RETURNING *; diff --git a/integrations/sqldelight/README.md b/integrations/sqldelight/README.md new file mode 100644 index 00000000..c60d1dd8 --- /dev/null +++ b/integrations/sqldelight/README.md @@ -0,0 +1,62 @@ +## PowerSync SQLDelight driver + +This library provides the `PowerSyncDriver` class, which implements an `SqlDriver` for `SQLDelight` +backed by PowerSync. + +## Setup + +Add a dependency on `com.powersync:integration-sqldelight`, using the same version you use for the +PowerSync SDK. + +## Usage + +To get started, ensure that SQLDelight is not linking sqlite3 (the PowerSync SDK takes care of that, +and you don't want to link it twice). Also, ensure the async generator is active because the +PowerSync driver does not support synchronous reads: + +```kotlin +sqldelight { + databases { + linkSqlite.set(false) + + create("MyAppDatabase") { + generateAsync.set(true) + deriveSchemaFromMigrations.set(false) + + dialect("app.cash.sqldelight:sqlite-3-38-dialect") + } + } +} +``` + +Next, define your tables in `.sq` files (but note that the `CREATE TABLE` statement won't be used, +PowerSync creates JSON-backed views for tables instead). +Open a PowerSync database [in the usual way](https://docs.powersync.com/client-sdk-references/kotlin-multiplatform#getting-started) +and finally pass it to the constructor of your generated SQLDelight database: + +```kotlin +val db: PowerSyncDatabase = openPowerSyncDatabase() +val yourSqlDelightDatabase = YourDatabase(PowerSyncDriver(db)) +``` + +Afterwards, writes on both databases (the original `PowerSyncDatabase` instance and the SQLDelight +database) will be visible to each other, update each other's query flows and will get synced +properly. + +## Limitations + +Please note that this library is currently in alpha. It is tested, but API changes are still +possible. + +There are also some limitations to be aware of: + +1. Due to historical reasons, the PowerSync SDK migrates all databases to `user_version` 1 when + created (but it will never downgrade a database). + So if you want to use SQLDelight's schema tools, the first version would have to be `2`. +2. The `CREATE TABLE` statements in your `.sq` files are only used at build time to verify your + queries. At runtime, PowerSync will create tables from your schema as views, the defined + statements are ignored. + If you want to use the schema managed by SQLDelight, configure PowerSync to use + [raw tables](https://docs.powersync.com/usage/use-case-examples/raw-tables). +3. Functions and tables contributed by the PowerSync core extension are not visible to `.sq` files + at the moment. We might revisit this with a custom dialect in the future. diff --git a/integrations/sqldelight/build.gradle.kts b/integrations/sqldelight/build.gradle.kts new file mode 100644 index 00000000..77007786 --- /dev/null +++ b/integrations/sqldelight/build.gradle.kts @@ -0,0 +1,70 @@ +import com.powersync.plugins.utils.powersyncTargets + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.android.library) + alias(libs.plugins.kotlinter) + alias(libs.plugins.kotlin.atomicfu) + id("com.powersync.plugins.sonatype") + id("com.powersync.plugins.sharedbuild") + id("dokka-convention") +} + +kotlin { + powersyncTargets() + explicitApi() + applyDefaultHierarchyTemplate() + + sourceSets { + commonMain.dependencies { + api(projects.core) + api(libs.sqldelight.runtime) + implementation(libs.kotlinx.coroutines.core) + } + + commonTest.dependencies { + // Separate project because SQLDelight can't generate code in test source sets. + implementation(projects.integrations.sqldelightTestDatabase) + + implementation(libs.kotlin.test) + implementation(libs.kotlinx.io) + implementation(libs.test.turbine) + implementation(libs.test.coroutines) + implementation(libs.test.kotest.assertions) + + implementation(libs.sqldelight.coroutines) + } + + val commonIntegrationTest by creating { + dependsOn(commonTest.get()) + } + + // The PowerSync SDK links the core extension, so we can just run tests as-is. + jvmTest.get().dependsOn(commonIntegrationTest) + + // We have special setup in this build configuration to make these tests link the PowerSync extension, so they + // can run integration tests along with the executable for unit testing. + nativeTest.orNull?.dependsOn(commonIntegrationTest) + } +} + +android { + namespace = "com.powersync.drivers.common" + compileSdk = + libs.versions.android.compileSdk + .get() + .toInt() + defaultConfig { + minSdk = + libs.versions.android.minSdk + .get() + .toInt() + } + kotlin { + jvmToolchain(17) + } +} + +dokka { + moduleName.set("PowerSync for SQLDelight") +} diff --git a/integrations/sqldelight/gradle.properties b/integrations/sqldelight/gradle.properties new file mode 100644 index 00000000..a38406cd --- /dev/null +++ b/integrations/sqldelight/gradle.properties @@ -0,0 +1,3 @@ +POM_ARTIFACT_ID=integration-sqldelight +POM_NAME=PowerSync SQLDelight driver +POM_DESCRIPTION=Use a PowerSync database for your SQLDelight database. \ No newline at end of file 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 new file mode 100644 index 00000000..8cc9d44d --- /dev/null +++ b/integrations/sqldelight/src/appleTest/kotlin/com/powersync/integrations/sqldelight/SqlDelightTest.apple.kt @@ -0,0 +1,5 @@ +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 new file mode 100644 index 00000000..f3edf259 --- /dev/null +++ b/integrations/sqldelight/src/commonIntegrationTest/kotlin/com/powersync/integrations/sqldelight/SqlDelightTest.kt @@ -0,0 +1,172 @@ +package com.powersync.integrations.sqldelight + +import app.cash.sqldelight.async.coroutines.awaitAsList +import app.cash.sqldelight.async.coroutines.awaitAsOne +import app.cash.sqldelight.coroutines.asFlow +import app.cash.sqldelight.coroutines.mapToList +import app.cash.turbine.turbineScope +import com.powersync.DatabaseDriverFactory +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.assertions.throwables.shouldThrow +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.properties.shouldHaveValue +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import kotlinx.io.files.SystemTemporaryDirectory +import kotlin.test.Test + +class SqlDelightTest { + @Test + fun simpleQueries() = + databaseTest { powersync -> + val db = TestDatabase(PowerSyncDriver(powersync, this)) + val query = db.todosQueries.all() + query.awaitAsList() shouldBe emptyList() + + db.todosQueries.create("my title", "my content") + query.awaitAsList().map { it.title } shouldBe listOf("my title") + } + + @Test + fun writeCreatesCrudEntry() = + databaseTest { powersync -> + val db = TestDatabase(PowerSyncDriver(powersync, this)) + db.todosQueries.create("my title", "my content") + + val tx = powersync.getNextCrudTransaction()!! + val item = tx.crud.single() + item::table shouldHaveValue "todos" + item.opData shouldBe mapOf("title" to "my title", "content" to "my content") + } + + @Test + fun powerSyncUpdatesSqlDelight() = + databaseTest { powersync -> + val db = TestDatabase(PowerSyncDriver(powersync, this)) + turbineScope { + val turbine = + db.todosQueries + .all() + .asFlow() + .mapToList(currentCoroutineContext()) + .testIn(this) + turbine.awaitItem() shouldBe emptyList() + + // Emulate data from the PowerSync service + powersync.execute( + "INSERT INTO ps_data__todos (id, data) VALUES (?, ?)", + listOf("server_id", """{"title": "from service", "content": "synced content"}"""), + ) + + val row = turbine.awaitItem().single() + row::title shouldHaveValue "from service" + row::content shouldHaveValue "synced content" + + turbine.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun sqlDelightUpdatesPowerSync() = + databaseTest { powersync -> + val db = TestDatabase(PowerSyncDriver(powersync, this)) + turbineScope { + val turbine = powersync.watch("SELECT title FROM todos") { it.getString(0)!! }.testIn(this) + turbine.awaitItem() shouldBe emptyList() + + db.todosQueries.create("title", "content") + turbine.awaitItem() shouldBe listOf("title") + + turbine.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun testReturningQuery() = + databaseTest { powersync -> + val db = TestDatabase(PowerSyncDriver(powersync, this)) + turbineScope { + db.todosQueries.create("title", "content") + + val query = + db.todosQueries + .all() + .asFlow() + .mapToList(currentCoroutineContext()) + .testIn(this) + query.awaitItem() shouldHaveSize 1 + + val updatedItem = db.todosQueries.update().awaitAsOne() + updatedItem.content shouldBe "contenttitle" + query.awaitItem() shouldBe listOf(updatedItem) + query.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun testTransaction() = + databaseTest { powersync -> + val db = TestDatabase(PowerSyncDriver(powersync, this)) + turbineScope { + val turbine = powersync.watch("SELECT title FROM todos") { it.getString(0)!! }.testIn(this) + turbine.awaitItem() shouldBe emptyList() + + db.transaction { + db.todosQueries.create("first", "first content") + db.todosQueries.create("second", "second content") + } + + // Should commit atomically + turbine.awaitItem() shouldBe listOf("first", "second") + turbine.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun testTransactionRollback() = + databaseTest { powersync -> + val db = TestDatabase(PowerSyncDriver(powersync, this)) + shouldThrow { + db.transaction { + db.todosQueries.create("first", "first content") + throw Exception("Test exception for rollback") + } + } + + db.todosQueries.all().awaitAsList() shouldHaveSize 0 + } +} + +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(), + schema = + Schema( + Table( + "todos", + listOf( + Column.text("title"), + Column.text("content"), + ), + ), + ), + dbFilename = "db-$suffix", + dbDirectory = SystemTemporaryDirectory.toString(), + ) + + body(db) + db.close() + } +} + +expect fun databaseDriverFactory(): DatabaseDriverFactory diff --git a/integrations/sqldelight/src/commonMain/kotlin/com/powersync/integrations/sqldelight/PowerSyncDriver.kt b/integrations/sqldelight/src/commonMain/kotlin/com/powersync/integrations/sqldelight/PowerSyncDriver.kt new file mode 100644 index 00000000..420288c7 --- /dev/null +++ b/integrations/sqldelight/src/commonMain/kotlin/com/powersync/integrations/sqldelight/PowerSyncDriver.kt @@ -0,0 +1,300 @@ +package com.powersync.integrations.sqldelight + +import androidx.sqlite.SQLiteStatement +import app.cash.sqldelight.Query +import app.cash.sqldelight.Transacter +import app.cash.sqldelight.db.QueryResult +import app.cash.sqldelight.db.SqlCursor +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.db.SqlPreparedStatement +import com.powersync.ExperimentalPowerSyncAPI +import com.powersync.PowerSyncDatabase +import com.powersync.db.driver.SQLiteConnectionLease +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +/** + * A driver for SQLDelight that delegates queries to an opened PowerSync database. + * + * Writes made through SQLDelight will trigger entries in the CRUD queue for PowerSync, allowing + * them to be uploaded. + * Similarly, writes made on the PowerSync database (both locally and those made during syncing) + * will update SQLDelight queries and flows. + * + * This driver implements [SqlDriver] and can be passed to constructors of your SQLDelight database. + * Please see the readme of this library for more details to be aware of. + */ +@OptIn(ExperimentalPowerSyncAPI::class) +public class PowerSyncDriver( + private val db: PowerSyncDatabase, + private val scope: CoroutineScope, +) : SynchronizedObject(), + SqlDriver { + private var transaction: PowerSyncTransaction? = null + private var listeners: MutableMap = mutableMapOf() + + private suspend inline fun withConnection(crossinline body: suspend (SQLiteConnectionLease) -> T): T { + transaction?.let { tx -> + return body(tx.connection) + } + + return db.useConnection(readOnly = false) { body(it) } + } + + override fun executeQuery( + identifier: Int?, + sql: String, + mapper: (SqlCursor) -> QueryResult, + parameters: Int, + binders: (SqlPreparedStatement.() -> Unit)?, + ): QueryResult = + QueryResult.AsyncValue { + // Despite being a query, this is not guaranteed to be read-only: RETURNING statements + // also use this. + // So, always using the write connection is a safe default. In the future we may want to + // analyze the statement to potentially route it to a read connection if possible. + withConnection { connection -> + connection.usePrepared(sql) { stmt -> + val wrapper = StatementWrapper(stmt) + binders?.let { it(wrapper) } + + mapper(wrapper).value + } + } + } + + override fun execute( + identifier: Int?, + sql: String, + parameters: Int, + binders: (SqlPreparedStatement.() -> Unit)?, + ): QueryResult = + QueryResult.AsyncValue { + withConnection { connection -> + connection.usePrepared(sql) { stmt -> + val wrapper = StatementWrapper(stmt) + binders?.let { it(wrapper) } + + while (stmt.step()) { + // Keep stepping through statement + } + } + + connection.usePrepared("SELECT changes()") { + check(it.step()) + it.getLong(0) + } + } + } + + override fun newTransaction(): QueryResult = + QueryResult.AsyncValue { + val tx = + transaction?.let { outerTx -> + PowerSyncTransaction(outerTx) + } ?: newOutermostTransaction() + + tx.also { + it.begin() + transaction = it + } + } + + private suspend fun newOutermostTransaction(): PowerSyncTransaction { + val connectionAvailable = CompletableDeferred() + val connectionDone = CompletableDeferred() + + scope.launch { + db.useConnection(readOnly = false) { + connectionAvailable.complete(it) + connectionDone.await() + } + } + + return PowerSyncTransaction(this, connectionAvailable.await(), connectionDone) + } + + override fun currentTransaction(): Transacter.Transaction? = transaction + + internal fun endTransaction(transaction: PowerSyncTransaction) { + check(this.transaction === transaction) { + "Ending transaction that isn't the latest one" + } + this.transaction = transaction.outer + } + + override fun addListener( + vararg queryKeys: String, + listener: Query.Listener, + ): Unit = + synchronized(this) { + val job = + scope.launch { + db.onChange(queryKeys.toSet(), triggerImmediately = false).collect { + listener.queryResultsChanged() + } + } + val previous = listeners.put(listener, job) + previous?.cancel(CancellationException("Listener has been replaced")) + } + + override fun removeListener( + vararg queryKeys: String, + listener: Query.Listener, + ): Unit = + synchronized(this) { + listeners[listener]?.cancel(CancellationException("Listener has been removed")) + } + + override fun notifyListeners(vararg queryKeys: String) { + // Not necessary, PowerSync uses update hooks to notify listeners. + } + + override fun close() {} +} + +@OptIn(ExperimentalPowerSyncAPI::class) +internal class PowerSyncTransaction( + val driver: PowerSyncDriver, + val connection: SQLiteConnectionLease, + private val returnLease: CompletableDeferred, + val depth: Int = 0, + override val enclosingTransaction: PowerSyncTransaction? = null, +) : Transacter.Transaction() { + val outer get() = enclosingTransaction + + constructor(outer: PowerSyncTransaction) : this( + outer.driver, + outer.connection, + outer.returnLease, + outer.depth + 1, + outer, + ) + + fun end() { + driver.endTransaction(this) + if (depth == 0) { + returnLease.complete(Unit) + } + } + + suspend fun begin() { + if (depth == 0) { + try { + connection.execSQL("BEGIN EXCLUSIVE") + } catch (e: Exception) { + // Couldn't start transaction -> release connection + end() + throw e + } + } else { + connection.execSQL("SAVEPOINT s$depth") + } + } + + suspend fun commit() { + if (depth == 0) { + connection.execSQL("COMMIT") + end() // Return lease + } else { + connection.execSQL("RELEASE s$depth") + } + } + + suspend fun rollback() { + if (depth == 0) { + connection.execSQL("ROLLBACK") + end() // Return lease + } else { + connection.execSQL("ROLLBACK TRANSACTION TO SAVEPOINT s$depth") + } + } + + override fun endTransaction(successful: Boolean): QueryResult = + QueryResult.AsyncValue { + if (successful) { + commit() + } else { + rollback() + } + } +} + +private class StatementWrapper( + private val stmt: SQLiteStatement, +) : SqlPreparedStatement, + SqlCursor { + private inline fun bindNullable( + index: Int, + value: T?, + bind: SQLiteStatement.(Int, T) -> Unit, + ) { + if (value == null) { + stmt.bindNull(index + 1) + } else { + stmt.bind(index + 1, value) + } + } + + private inline fun readNullable( + index: Int, + read: SQLiteStatement.(Int) -> T, + ): T? = + if (stmt.isNull(index)) { + null + } else { + stmt.read(index) + } + + override fun bindBytes( + index: Int, + bytes: ByteArray?, + ) { + bindNullable(index, bytes, SQLiteStatement::bindBlob) + } + + override fun bindLong( + index: Int, + long: Long?, + ) { + bindNullable(index, long, SQLiteStatement::bindLong) + } + + override fun bindDouble( + index: Int, + double: Double?, + ) { + bindNullable(index, double, SQLiteStatement::bindDouble) + } + + override fun bindString( + index: Int, + string: String?, + ) { + bindNullable(index, string, SQLiteStatement::bindText) + } + + override fun bindBoolean( + index: Int, + boolean: Boolean?, + ) { + bindNullable(index, boolean, SQLiteStatement::bindBoolean) + } + + override fun next(): QueryResult = QueryResult.Value(stmt.step()) + + override fun getString(index: Int): String? = readNullable(index, SQLiteStatement::getText) + + override fun getLong(index: Int): Long? = readNullable(index, SQLiteStatement::getLong) + + override fun getBytes(index: Int): ByteArray? = readNullable(index, SQLiteStatement::getBlob) + + override fun getDouble(index: Int): Double? = readNullable(index, SQLiteStatement::getDouble) + + override fun getBoolean(index: Int): Boolean? = readNullable(index, SQLiteStatement::getBoolean) +} 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 new file mode 100644 index 00000000..8cc9d44d --- /dev/null +++ b/integrations/sqldelight/src/jvmTest/kotlin/com/powersync/integrations/sqldelight/SqlDelightTest.jvm.kt @@ -0,0 +1,5 @@ +package com.powersync.integrations.sqldelight + +import com.powersync.DatabaseDriverFactory + +actual fun databaseDriverFactory(): DatabaseDriverFactory = DatabaseDriverFactory() diff --git a/plugins/sonatype/src/main/kotlin/com/powersync/plugins/utils/KmpUtils.kt b/plugins/build-plugin/src/main/kotlin/com/powersync/plugins/utils/KmpUtils.kt similarity index 84% rename from plugins/sonatype/src/main/kotlin/com/powersync/plugins/utils/KmpUtils.kt rename to plugins/build-plugin/src/main/kotlin/com/powersync/plugins/utils/KmpUtils.kt index 2e7f145e..386a01b1 100644 --- a/plugins/sonatype/src/main/kotlin/com/powersync/plugins/utils/KmpUtils.kt +++ b/plugins/build-plugin/src/main/kotlin/com/powersync/plugins/utils/KmpUtils.kt @@ -7,17 +7,20 @@ import org.jetbrains.kotlin.gradle.dsl.KotlinTargetContainerWithPresetFunctions public fun KotlinTargetContainerWithPresetFunctions.powersyncTargets( native: Boolean = true, jvm: Boolean = true, + android: Boolean = true, includeTargetsWithoutComposeSupport: Boolean = true, watchOS: Boolean = true, legacyJavaSupport: Boolean = true, ) { if (jvm) { - androidTarget { - publishLibraryVariants("release", "debug") + if (android) { + androidTarget { + publishLibraryVariants("release", "debug") - @OptIn(ExperimentalKotlinGradlePluginApi::class) - compilerOptions { - jvmTarget.set(JvmTarget.JVM_17) + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } } } diff --git a/settings.gradle.kts b/settings.gradle.kts index a005f022..c66d4964 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -35,6 +35,9 @@ include(":connectors:supabase") include(":integrations:room") include(":static-sqlite-driver") +include(":integrations:sqldelight") +include(":integrations:sqldelight-test-database") + include(":PowerSyncKotlin") include(":compose")