Skip to content

Typed crud values #239

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Aug 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
67 changes: 67 additions & 0 deletions core/src/commonIntegrationTest/kotlin/com/powersync/CrudTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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!
)
}
}
18 changes: 6 additions & 12 deletions core/src/commonMain/kotlin/com/powersync/db/crud/CrudEntry.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -57,14 +57,14 @@ public data class CrudEntry(
*
* For DELETE, this is null.
*/
val opData: Map<String, String?>?,
val opData: SqliteRow?,
/**
* Previous values before this change.
*
* These values can be tracked for `UPDATE` statements when [Table.trackPreviousValues] is
* enabled.
*/
val previousValues: Map<String, String?>? = null,
val previousValues: SqliteRow? = null,
) {
public companion object {
public fun fromRow(row: CrudRow): CrudEntry {
Expand All @@ -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) },
)
}
}
Expand Down
93 changes: 93 additions & 0 deletions core/src/commonMain/kotlin/com/powersync/db/crud/SerializedRow.kt
Original file line number Diff line number Diff line change
@@ -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<String, String?>` to ensure compatible with earlier versions of the
* SDK, but the [typed] getter can be used to obtain a `Map<String, Any>` where values are either
* [String]s, [Int]s or [Double]s.
*/
@OptIn(ExperimentalObjCRefinement::class)
public interface SqliteRow : Map<String, String?> {
/**
* A typed view of the SQLite row.
*/
public val typed: Map<String, Any?>

/**
* 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<String, String?>(),
SqliteRow {
override val entries: Set<Map.Entry<String, String?>> =
jsonValues.entries.mapTo(
mutableSetOf(),
::ToStringEntry,
)

override val typed: Map<String, Any?> = TypedRow(jsonValues)
}

private data class ToStringEntry(
val inner: Map.Entry<String, JsonElement>,
) : Map.Entry<String, String> {
override val key: String
get() = inner.key
override val value: String
get() = inner.value.jsonPrimitive.content
}

private class TypedRow(
inner: JsonObject,
) : AbstractMap<String, Any?>() {
override val entries: Set<Map.Entry<String, Any?>> =
inner.entries.mapTo(
mutableSetOf(),
::ToTypedEntry,
)
}

private data class ToTypedEntry(
val inner: Map.Entry<String, JsonElement>,
) : Map.Entry<String, Any?> {
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()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,7 @@ class BucketStorageTest {
op = UpdateType.PUT,
table = "table1",
transactionId = 1,
opData =
mapOf(
"key" to "value",
),
opData = null,
)
mockDb =
mock<InternalDatabase> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,7 @@ class SyncStreamTest {
op = UpdateType.PUT,
table = "table1",
transactionId = 1,
opData =
mapOf(
"key" to "value",
),
opData = null,
)
bucketStorage =
mock<BucketStorage> {
Expand Down
Loading