From 3ff03fa813bfc1eba90920e9a5992377b4b259c2 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Tue, 5 Aug 2025 16:23:53 +0200 Subject: [PATCH 1/3] Add typed variants of CRUD data --- CHANGELOG.md | 2 + .../kotlin/com/powersync/CrudTest.kt | 55 ++++++++++++++++ .../kotlin/com/powersync/db/crud/CrudEntry.kt | 63 +++++++++++++++---- .../com/powersync/bucket/BucketStorageTest.kt | 1 + .../com/powersync/sync/SyncStreamTest.kt | 1 + 5 files changed, 111 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9039618..07b20160 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`: Add `data` and `typedPreviousValues` fields as typed variants of + `opData` and `previousValues`, respectively. ## 1.3.1 diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/CrudTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/CrudTest.kt index b3c930f9..c5f4d7e9 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/CrudTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/CrudTest.kt @@ -90,4 +90,59 @@ 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", + ), + ) + } + + val batch = database.getNextCrudTransaction()!! + batch.crud[0].data shouldBe + mapOf( + "a" to "text", + "b" to 42, + "c" to 13.37, + ) + batch.crud[0].typedPreviousValues shouldBe null + + batch.crud[1].data shouldBe + mapOf( + "a" to "te\"xt", + "b" to null, + ) + batch.crud[1].typedPreviousValues shouldBe + mapOf( + "a" to "text", + "b" to 42, + ) + } } 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..e43f8164 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/crud/CrudEntry.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/crud/CrudEntry.kt @@ -1,16 +1,17 @@ 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.JsonElement +import kotlinx.serialization.json.JsonNull 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,35 +58,75 @@ public data class CrudEntry( * * For DELETE, this is null. */ + @Deprecated("Use data instead", replaceWith = ReplaceWith("data")) val opData: Map?, + /** + * Data associated with the change. + * + * For PUT, this is contains all non-null columns of the row. + * + * For PATCH, this is contains the columns that changed. + * + * For DELETE, this is null. + */ + val data: Map?, /** * Previous values before this change. * * These values can be tracked for `UPDATE` statements when [Table.trackPreviousValues] is * enabled. */ + @Deprecated("Use typedPreviousValues instead", replaceWith = ReplaceWith("typedPreviousValues")) val previousValues: Map? = null, + /** + * Previous values before this change. + * + * These values can be tracked for `UPDATE` statements when [Table.trackPreviousValues] is + * enabled. + */ + val typedPreviousValues: Map? = null, ) { public companion object { public fun fromRow(row: CrudRow): CrudEntry { val data = JsonUtil.json.parseToJsonElement(row.data).jsonObject + val opData = data["data"]?.asData() + val previousValues = data["old"]?.asData() + return 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 = opData?.toStringMap(), + data = opData, table = data["type"]!!.jsonPrimitive.content, transactionId = row.txId, metadata = data["metadata"]?.jsonPrimitive?.content, - previousValues = - data["old"]?.jsonObject?.mapValues { (_, value) -> - value.jsonPrimitive.contentOrNull - }, + typedPreviousValues = previousValues, + previousValues = previousValues?.toStringMap(), ) } + + private fun JsonElement.asData(): Map = + jsonObject.mapValues { (_, value) -> + val primitive = value.jsonPrimitive + if (primitive === JsonNull) { + null + } else if (primitive.isString) { + primitive.content + } else { + primitive.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() + } + + private fun Map.toStringMap(): Map = mapValues { (_, v) -> v.toString() } } override fun toString(): String = "CrudEntry<$transactionId/$clientId ${op.toJson()} $table/$id $opData>" diff --git a/core/src/commonTest/kotlin/com/powersync/bucket/BucketStorageTest.kt b/core/src/commonTest/kotlin/com/powersync/bucket/BucketStorageTest.kt index b169f88f..64f3a0d4 100644 --- a/core/src/commonTest/kotlin/com/powersync/bucket/BucketStorageTest.kt +++ b/core/src/commonTest/kotlin/com/powersync/bucket/BucketStorageTest.kt @@ -79,6 +79,7 @@ class BucketStorageTest { mapOf( "key" to "value", ), + data = mapOf("key" to "value"), ) 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..a621379e 100644 --- a/core/src/commonTest/kotlin/com/powersync/sync/SyncStreamTest.kt +++ b/core/src/commonTest/kotlin/com/powersync/sync/SyncStreamTest.kt @@ -106,6 +106,7 @@ class SyncStreamTest { mapOf( "key" to "value", ), + data = mapOf("key" to "value"), ) bucketStorage = mock { From a8dfaf3c339ec36f676fb228bb926aad0e09426a Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Tue, 5 Aug 2025 16:27:59 +0200 Subject: [PATCH 2/3] Test with int as text --- .../kotlin/com/powersync/CrudTest.kt | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/CrudTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/CrudTest.kt index c5f4d7e9..cc5f2c21 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/CrudTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/CrudTest.kt @@ -125,7 +125,7 @@ class CrudTest { ) } - val batch = database.getNextCrudTransaction()!! + var batch = database.getNextCrudTransaction()!! batch.crud[0].data shouldBe mapOf( "a" to "text", @@ -144,5 +144,17 @@ class CrudTest { "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].data shouldBe + mapOf( + "a" to "42", // Not an integer! + ) } } From 1730170fd7c2ee504ebf2605bedb1c5d649be636 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Tue, 5 Aug 2025 17:33:23 +0200 Subject: [PATCH 3/3] Use separate interface --- CHANGELOG.md | 4 +- .../connector/supabase/SupabaseConnector.kt | 12 ++- .../kotlin/com/powersync/CrudTest.kt | 10 +- .../kotlin/com/powersync/db/crud/CrudEntry.kt | 57 +----------- .../com/powersync/db/crud/SerializedRow.kt | 93 +++++++++++++++++++ .../com/powersync/bucket/BucketStorageTest.kt | 6 +- .../com/powersync/sync/SyncStreamTest.kt | 6 +- 7 files changed, 114 insertions(+), 74 deletions(-) create mode 100644 core/src/commonMain/kotlin/com/powersync/db/crud/SerializedRow.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 07b20160..1d9396b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +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`: Add `data` and `typedPreviousValues` fields as typed variants of - `opData` and `previousValues`, respectively. +* `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 cc5f2c21..4ba15d9b 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/CrudTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/CrudTest.kt @@ -126,20 +126,20 @@ class CrudTest { } var batch = database.getNextCrudTransaction()!! - batch.crud[0].data shouldBe + batch.crud[0].opData?.typed shouldBe mapOf( "a" to "text", "b" to 42, "c" to 13.37, ) - batch.crud[0].typedPreviousValues shouldBe null + batch.crud[0].previousValues shouldBe null - batch.crud[1].data shouldBe + batch.crud[1].opData?.typed shouldBe mapOf( "a" to "te\"xt", "b" to null, ) - batch.crud[1].typedPreviousValues shouldBe + batch.crud[1].previousValues?.typed shouldBe mapOf( "a" to "text", "b" to 42, @@ -152,7 +152,7 @@ class CrudTest { ) batch = database.getNextCrudTransaction()!! - batch.crud[0].data shouldBe + 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 e43f8164..293b5ea3 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/crud/CrudEntry.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/crud/CrudEntry.kt @@ -1,9 +1,8 @@ package com.powersync.db.crud +import com.powersync.PowerSyncDatabase import com.powersync.db.schema.Table import com.powersync.utils.JsonUtil -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive @@ -58,75 +57,29 @@ public data class CrudEntry internal constructor( * * For DELETE, this is null. */ - @Deprecated("Use data instead", replaceWith = ReplaceWith("data")) - val opData: Map?, - /** - * Data associated with the change. - * - * For PUT, this is contains all non-null columns of the row. - * - * For PATCH, this is contains the columns that changed. - * - * For DELETE, this is null. - */ - val data: Map?, + val opData: SqliteRow?, /** * Previous values before this change. * * These values can be tracked for `UPDATE` statements when [Table.trackPreviousValues] is * enabled. */ - @Deprecated("Use typedPreviousValues instead", replaceWith = ReplaceWith("typedPreviousValues")) - val previousValues: Map? = null, - /** - * Previous values before this change. - * - * These values can be tracked for `UPDATE` statements when [Table.trackPreviousValues] is - * enabled. - */ - val typedPreviousValues: Map? = null, + val previousValues: SqliteRow? = null, ) { public companion object { public fun fromRow(row: CrudRow): CrudEntry { val data = JsonUtil.json.parseToJsonElement(row.data).jsonObject - val opData = data["data"]?.asData() - val previousValues = data["old"]?.asData() - return CrudEntry( id = data["id"]!!.jsonPrimitive.content, clientId = row.id.toInt(), op = UpdateType.fromJsonChecked(data["op"]!!.jsonPrimitive.content), - opData = opData?.toStringMap(), - data = opData, + opData = data["data"]?.let { SerializedRow(it.jsonObject) }, table = data["type"]!!.jsonPrimitive.content, transactionId = row.txId, metadata = data["metadata"]?.jsonPrimitive?.content, - typedPreviousValues = previousValues, - previousValues = previousValues?.toStringMap(), + previousValues = data["old"]?.let { SerializedRow(it.jsonObject) }, ) } - - private fun JsonElement.asData(): Map = - jsonObject.mapValues { (_, value) -> - val primitive = value.jsonPrimitive - if (primitive === JsonNull) { - null - } else if (primitive.isString) { - primitive.content - } else { - primitive.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() - } - - private fun Map.toStringMap(): Map = mapValues { (_, v) -> v.toString() } } override fun toString(): String = "CrudEntry<$transactionId/$clientId ${op.toJson()} $table/$id $opData>" 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 64f3a0d4..512a90e1 100644 --- a/core/src/commonTest/kotlin/com/powersync/bucket/BucketStorageTest.kt +++ b/core/src/commonTest/kotlin/com/powersync/bucket/BucketStorageTest.kt @@ -75,11 +75,7 @@ class BucketStorageTest { op = UpdateType.PUT, table = "table1", transactionId = 1, - opData = - mapOf( - "key" to "value", - ), - data = 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 a621379e..3872da0a 100644 --- a/core/src/commonTest/kotlin/com/powersync/sync/SyncStreamTest.kt +++ b/core/src/commonTest/kotlin/com/powersync/sync/SyncStreamTest.kt @@ -102,11 +102,7 @@ class SyncStreamTest { op = UpdateType.PUT, table = "table1", transactionId = 1, - opData = - mapOf( - "key" to "value", - ), - data = mapOf("key" to "value"), + opData = null, ) bucketStorage = mock {