diff --git a/CHANGELOG.md b/CHANGELOG.md index a9039618..1d9396b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ * Added the ability to log PowerSync service HTTP request information via specifying a `SyncClientConfiguration` in the `SyncOptions.clientConfiguration` parameter used in `PowerSyncDatabase.connect()` calls. +* `CrudEntry`: Introduce `SqliteRow` interface for `opData` and `previousValues`, providing typed + access to the underlying values. ## 1.3.1 diff --git a/connectors/supabase/src/commonMain/kotlin/com/powersync/connector/supabase/SupabaseConnector.kt b/connectors/supabase/src/commonMain/kotlin/com/powersync/connector/supabase/SupabaseConnector.kt index 43895927..12913cda 100644 --- a/connectors/supabase/src/commonMain/kotlin/com/powersync/connector/supabase/SupabaseConnector.kt +++ b/connectors/supabase/src/commonMain/kotlin/com/powersync/connector/supabase/SupabaseConnector.kt @@ -26,6 +26,7 @@ import io.ktor.client.statement.bodyAsText import io.ktor.utils.io.InternalAPI import kotlinx.coroutines.flow.StateFlow import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive /** * Get a Supabase token to authenticate against the PowerSync instance. @@ -190,19 +191,20 @@ public class SupabaseConnector( when (entry.op) { UpdateType.PUT -> { - val data = entry.opData?.toMutableMap() ?: mutableMapOf() - data["id"] = entry.id + val data = + buildMap { + put("id", JsonPrimitive(entry.id)) + entry.opData?.jsonValues?.let { putAll(it) } + } table.upsert(data) } - UpdateType.PATCH -> { - table.update(entry.opData!!) { + table.update(entry.opData!!.jsonValues) { filter { eq("id", entry.id) } } } - UpdateType.DELETE -> { table.delete { filter { diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/CrudTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/CrudTest.kt index b3c930f9..4ba15d9b 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/CrudTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/CrudTest.kt @@ -90,4 +90,71 @@ class CrudTest { val batch = database.getNextCrudTransaction() batch shouldBe null } + + @Test + fun typedUpdates() = + databaseTest { + database.updateSchema( + Schema( + Table( + "foo", + listOf( + Column.text("a"), + Column.integer("b"), + Column.integer("c"), + ), + trackPreviousValues = TrackPreviousValuesOptions(onlyWhenChanged = true), + ), + ), + ) + + database.writeTransaction { tx -> + tx.execute( + "INSERT INTO foo (id,a,b,c) VALUES (uuid(), ?, ?, ?)", + listOf( + "text", + 42, + 13.37, + ), + ) + tx.execute( + "UPDATE foo SET a = ?, b = NULL", + listOf( + "te\"xt", + ), + ) + } + + var batch = database.getNextCrudTransaction()!! + batch.crud[0].opData?.typed shouldBe + mapOf( + "a" to "text", + "b" to 42, + "c" to 13.37, + ) + batch.crud[0].previousValues shouldBe null + + batch.crud[1].opData?.typed shouldBe + mapOf( + "a" to "te\"xt", + "b" to null, + ) + batch.crud[1].previousValues?.typed shouldBe + mapOf( + "a" to "text", + "b" to 42, + ) + + database.execute("DELETE FROM ps_crud") + database.execute( + "UPDATE foo SET a = ?", + listOf("42"), + ) + + batch = database.getNextCrudTransaction()!! + batch.crud[0].opData?.typed shouldBe + mapOf( + "a" to "42", // Not an integer! + ) + } } diff --git a/core/src/commonMain/kotlin/com/powersync/db/crud/CrudEntry.kt b/core/src/commonMain/kotlin/com/powersync/db/crud/CrudEntry.kt index 1b3366a4..293b5ea3 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/crud/CrudEntry.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/crud/CrudEntry.kt @@ -3,14 +3,14 @@ package com.powersync.db.crud import com.powersync.PowerSyncDatabase import com.powersync.db.schema.Table import com.powersync.utils.JsonUtil -import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive /** * A single client-side change. */ -public data class CrudEntry( +@ConsistentCopyVisibility +public data class CrudEntry internal constructor( /** * ID of the changed row. */ @@ -57,14 +57,14 @@ public data class CrudEntry( * * For DELETE, this is null. */ - val opData: Map?, + val opData: SqliteRow?, /** * Previous values before this change. * * These values can be tracked for `UPDATE` statements when [Table.trackPreviousValues] is * enabled. */ - val previousValues: Map? = null, + val previousValues: SqliteRow? = null, ) { public companion object { public fun fromRow(row: CrudRow): CrudEntry { @@ -73,17 +73,11 @@ public data class CrudEntry( id = data["id"]!!.jsonPrimitive.content, clientId = row.id.toInt(), op = UpdateType.fromJsonChecked(data["op"]!!.jsonPrimitive.content), - opData = - data["data"]?.jsonObject?.mapValues { (_, value) -> - value.jsonPrimitive.contentOrNull - }, + opData = data["data"]?.let { SerializedRow(it.jsonObject) }, table = data["type"]!!.jsonPrimitive.content, transactionId = row.txId, metadata = data["metadata"]?.jsonPrimitive?.content, - previousValues = - data["old"]?.jsonObject?.mapValues { (_, value) -> - value.jsonPrimitive.contentOrNull - }, + previousValues = data["old"]?.let { SerializedRow(it.jsonObject) }, ) } } diff --git a/core/src/commonMain/kotlin/com/powersync/db/crud/SerializedRow.kt b/core/src/commonMain/kotlin/com/powersync/db/crud/SerializedRow.kt new file mode 100644 index 00000000..38499c8e --- /dev/null +++ b/core/src/commonMain/kotlin/com/powersync/db/crud/SerializedRow.kt @@ -0,0 +1,93 @@ +package com.powersync.db.crud + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonPrimitive +import kotlin.experimental.ExperimentalObjCRefinement +import kotlin.native.HiddenFromObjC + +/** + * A named collection of values as they appear in a SQLite row. + * + * We represent values as a `Map` to ensure compatible with earlier versions of the + * SDK, but the [typed] getter can be used to obtain a `Map` where values are either + * [String]s, [Int]s or [Double]s. + */ +@OptIn(ExperimentalObjCRefinement::class) +public interface SqliteRow : Map { + /** + * A typed view of the SQLite row. + */ + public val typed: Map + + /** + * A [JsonObject] of all values in this row that can be represented as JSON. + */ + @HiddenFromObjC + public val jsonValues: JsonObject +} + +/** + * A [SqliteRow] implemented over a [JsonObject] view. + */ +internal class SerializedRow( + override val jsonValues: JsonObject, +) : AbstractMap(), + SqliteRow { + override val entries: Set> = + jsonValues.entries.mapTo( + mutableSetOf(), + ::ToStringEntry, + ) + + override val typed: Map = TypedRow(jsonValues) +} + +private data class ToStringEntry( + val inner: Map.Entry, +) : Map.Entry { + override val key: String + get() = inner.key + override val value: String + get() = inner.value.jsonPrimitive.content +} + +private class TypedRow( + inner: JsonObject, +) : AbstractMap() { + override val entries: Set> = + inner.entries.mapTo( + mutableSetOf(), + ::ToTypedEntry, + ) +} + +private data class ToTypedEntry( + val inner: Map.Entry, +) : Map.Entry { + override val key: String + get() = inner.key + override val value: Any? + get() = inner.value.jsonPrimitive.asData() + + companion object { + private fun JsonPrimitive.asData(): Any? = + if (this === JsonNull) { + null + } else if (isString) { + content + } else { + content.jsonNumberOrBoolean() + } + + private fun String.jsonNumberOrBoolean(): Any = + when { + this == "true" -> true + this == "false" -> false + this.any { char -> char == '.' || char == 'e' || char == 'E' } -> this.toDouble() + else -> this.toInt() + } + } +} diff --git a/core/src/commonTest/kotlin/com/powersync/bucket/BucketStorageTest.kt b/core/src/commonTest/kotlin/com/powersync/bucket/BucketStorageTest.kt index b169f88f..512a90e1 100644 --- a/core/src/commonTest/kotlin/com/powersync/bucket/BucketStorageTest.kt +++ b/core/src/commonTest/kotlin/com/powersync/bucket/BucketStorageTest.kt @@ -75,10 +75,7 @@ class BucketStorageTest { op = UpdateType.PUT, table = "table1", transactionId = 1, - opData = - mapOf( - "key" to "value", - ), + opData = null, ) mockDb = mock { diff --git a/core/src/commonTest/kotlin/com/powersync/sync/SyncStreamTest.kt b/core/src/commonTest/kotlin/com/powersync/sync/SyncStreamTest.kt index 6cc414b3..3872da0a 100644 --- a/core/src/commonTest/kotlin/com/powersync/sync/SyncStreamTest.kt +++ b/core/src/commonTest/kotlin/com/powersync/sync/SyncStreamTest.kt @@ -102,10 +102,7 @@ class SyncStreamTest { op = UpdateType.PUT, table = "table1", transactionId = 1, - opData = - mapOf( - "key" to "value", - ), + opData = null, ) bucketStorage = mock {