diff --git a/README.md b/README.md index cd8e7cba..5c2191fa 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,10 @@ and API documentation [here](https://powersync-ja.github.io/powersync-kotlin/). 1. Retrieve a token to connect to the PowerSync service. 2. Apply local changes on your backend application server (and from there, to your backend database). +- [integrations](./integrations/) + - [sqldelight](./integrations/sqldelight/): An experimental SQLDelight driver backed by PowerSync databases. + Changes to the PowerSync database, including those from the server, update SQLDelight queries. + ## Demo Apps / Example Projects The easiest way to test the PowerSync KMP SDK is to run one of our demo applications. diff --git a/build.gradle.kts b/build.gradle.kts index e7477f6b..86c84eee 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -18,6 +18,7 @@ plugins { alias(libs.plugins.kotlinter) apply false alias(libs.plugins.keeper) apply false alias(libs.plugins.kotlin.atomicfu) apply false + alias(libs.plugins.sqldelight) apply false id("org.jetbrains.dokka") version libs.versions.dokkaBase id("dokka-convention") } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index de6c06c7..bd15114a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,6 +21,7 @@ powersync-core = "0.4.2" sqlite-jdbc = "3.50.3.0" turbine = "1.2.0" kotest = "5.9.1" +sqldelight = "2.1.0" stately = "2.1.0" supabase = "3.0.1" @@ -95,6 +96,10 @@ supabase-storage = { module = "io.github.jan-tennert.supabase:storage-kt", versi androidx-sqlite = { module = "androidx.sqlite:sqlite", version.ref = "androidxSqlite" } androidx-sqliteFramework = { module = "androidx.sqlite:sqlite-framework", version.ref = "androidxSqlite" } +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" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" } @@ -121,3 +126,4 @@ kotlinter = { id = "org.jmailen.kotlinter", version.ref = "kotlinter" } keeper = { id = "com.slack.keeper", version.ref = "keeper" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-atomicfu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "atomicfu" } +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..5008c45c --- /dev/null +++ b/integrations/sqldelight-test-database/build.gradle.kts @@ -0,0 +1,34 @@ +import com.powersync.plugins.utils.powersyncTargets + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.kotlinter) + alias(libs.plugins.sqldelight) + id("com.powersync.plugins.sonatype") +} + +kotlin { + // Disabling Android for simplicity, we're only testing the common driver anyway + 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) + } + } +} 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..38496680 --- /dev/null +++ b/integrations/sqldelight-test-database/src/commonMain/sqldelight/com/powersync/integrations/sqldelight/todos.sq @@ -0,0 +1,11 @@ +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(), ?, ?); diff --git a/integrations/sqldelight/README.md b/integrations/sqldelight/README.md new file mode 100644 index 00000000..4e80ad64 --- /dev/null +++ b/integrations/sqldelight/README.md @@ -0,0 +1,55 @@ +## PowerSync SQLDelight driver + +This library provides the `PowerSyncDriver` class, which implements an `SqlDriver` for `SQLDelight` +backed by PowerSync. + +## 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. While you can write `CREATE TABLE` statements in your `.sq` files, note that these aren't + actually used - you still have to define your PowerSync schema and the SDK will auto-create the + tables from there. +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..46a4df30 --- /dev/null +++ b/integrations/sqldelight/build.gradle.kts @@ -0,0 +1,57 @@ +import com.powersync.plugins.utils.powersyncTargets + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidLibrary) + alias(libs.plugins.kotlinter) + alias(libs.plugins.kotlin.atomicfu) + id("com.powersync.plugins.sonatype") + id("dokka-convention") +} + +kotlin { + powersyncTargets() + explicitApi() + applyDefaultHierarchyTemplate() + + sourceSets { + commonMain.dependencies { + api(projects.core) + api(libs.sqldelight.runtime) + implementation(libs.kotlinx.coroutines.core) + } + + jvmTest.dependencies { + // Separate project because SQLDelight can't generate code in test source sets. + implementation(projects.integrations.sqldelightTestDatabase) + + implementation(libs.kotlin.test) + implementation(libs.test.turbine) + implementation(libs.test.coroutines) + implementation(libs.test.kotest.assertions) + + implementation(libs.sqldelight.coroutines) + } + } +} + +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/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..b4e836d2 --- /dev/null +++ b/integrations/sqldelight/src/commonMain/kotlin/com/powersync/integrations/sqldelight/PowerSyncDriver.kt @@ -0,0 +1,249 @@ +package com.powersync.integrations.sqldelight + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteStatement +import androidx.sqlite.execSQL +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 kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized +import kotlinx.coroutines.CancellationException +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(body: (SQLiteConnection) -> T): T { + transaction?.let { tx -> + return body(tx.connection) + } + + return db.leaseConnection(readOnly = false).use(body) + } + + override fun executeQuery( + identifier: Int?, + sql: String, + mapper: (SqlCursor) -> QueryResult, + parameters: Int, + binders: (SqlPreparedStatement.() -> Unit)? + ): QueryResult { + return QueryResult.AsyncValue { + withConnection { connection -> + connection.prepare(sql).use { stmt -> + val wrapper = StatementWrapper(stmt) + binders?.let { it(wrapper) } + + mapper(wrapper).await() + } + } + } + } + + override fun execute( + identifier: Int?, + sql: String, + parameters: Int, + binders: (SqlPreparedStatement.() -> Unit)? + ): QueryResult { + return QueryResult.AsyncValue { + withConnection { connection -> + connection.prepare(sql).use { stmt -> + val wrapper = StatementWrapper(stmt) + binders?.let { it(wrapper) } + + while (stmt.step()) { + // Keep stepping through statement + } + + 0L + } + } + } + } + + override fun newTransaction(): QueryResult { + return QueryResult.AsyncValue { + val tx = transaction?.let { outerTx -> + PowerSyncTransaction(outerTx) + } ?: PowerSyncTransaction(db.leaseConnection(readOnly = false)) + + tx.also { + it.begin() + transaction = it + } + } + } + + override fun currentTransaction(): Transacter.Transaction? { + return transaction + } + + 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() {} +} + +private class PowerSyncTransaction( + val connection: SQLiteConnection, + val depth: Int = 0, + override val enclosingTransaction: Transacter.Transaction? = null +) : Transacter.Transaction() { + constructor(outer: PowerSyncTransaction) : this( + outer.connection, + outer.depth + 1, + outer + ) + + fun begin() { + if (depth == 0) { + try { + connection.execSQL("BEGIN EXCLUSIVE") + } catch (e: Exception) { + // Couldn't start transaction -> release connection + connection.close() + throw e + } + + } else { + connection.execSQL("SAVEPOINT s${depth}") + } + } + + fun commit() { + if (depth == 0) { + connection.execSQL("COMMIT") + connection.close() // Return lease + } else { + connection.execSQL("RELEASE s${depth}") + } + } + + fun rollback() { + if (depth == 0) { + connection.execSQL("ROLLBACK") + connection.close() // Return lease + } else { + connection.execSQL("ROLLBACK TRANSACTION TO SAVEPOINT s${depth}") + } + } + + override fun endTransaction(successful: Boolean): QueryResult { + if (successful) { + commit() + } else { + rollback() + } + + return QueryResult.Unit + } +} + +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? { + return 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 { + return QueryResult.Value(stmt.step()) + } + + override fun getString(index: Int): String? { + return readNullable(index, SQLiteStatement::getText) + } + + override fun getLong(index: Int): Long? { + return readNullable(index, SQLiteStatement::getLong) + } + + override fun getBytes(index: Int): ByteArray? { + return readNullable(index, SQLiteStatement::getBlob) + } + + override fun getDouble(index: Int): Double? { + return readNullable(index, SQLiteStatement::getDouble) + } + + override fun getBoolean(index: Int): Boolean? { + return readNullable(index, SQLiteStatement::getBoolean) + } +} diff --git a/integrations/sqldelight/src/jvmTest/kotlin/com/powersync/integrations/sqldelight/SqlDelightTest.kt b/integrations/sqldelight/src/jvmTest/kotlin/com/powersync/integrations/sqldelight/SqlDelightTest.kt new file mode 100644 index 00000000..35f8d2f5 --- /dev/null +++ b/integrations/sqldelight/src/jvmTest/kotlin/com/powersync/integrations/sqldelight/SqlDelightTest.kt @@ -0,0 +1,113 @@ +package com.powersync.integrations.sqldelight + +import app.cash.sqldelight.async.coroutines.awaitAsList +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.matchers.properties.shouldHaveValue +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +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 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() + } + } +} + +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 = System.getProperty("java.io.tmpdir") + ) + + body(db) + db.close() + } +} diff --git a/plugins/sonatype/src/main/kotlin/com/powersync/plugins/utils/KmpUtils.kt b/plugins/sonatype/src/main/kotlin/com/powersync/plugins/utils/KmpUtils.kt index 22d139ab..b7f151c2 100644 --- a/plugins/sonatype/src/main/kotlin/com/powersync/plugins/utils/KmpUtils.kt +++ b/plugins/sonatype/src/main/kotlin/com/powersync/plugins/utils/KmpUtils.kt @@ -7,16 +7,19 @@ 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, ) { if (jvm) { - androidTarget { - publishLibraryVariants("release", "debug") - - @OptIn(ExperimentalKotlinGradlePluginApi::class) - compilerOptions { - jvmTarget.set(JvmTarget.JVM_17) + if (android) { + androidTarget { + publishLibraryVariants("release", "debug") + + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 51df18b0..92cee02d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -31,5 +31,7 @@ include(":PowerSyncKotlin") include(":drivers:common") include(":compose") +include(":integrations:sqldelight") +include(":integrations:sqldelight-test-database") enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")