Skip to content

Add new schema options #181

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 6 commits into from
May 5, 2025
Merged
Show file tree
Hide file tree
Changes from 2 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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## unreleased

* Add `includeOld` option on `Table` which sets `CrudEntry.oldData` to previous values on updates.
* Add `includeMetadata` option on `Table` which adds a `_metadata` column that can be used for updates.
The configured metadata is available through `CrudEntry.metadata`.
* Add `ignoreEmptyUpdate` option which skips creating CRUD entries for updates that don't change any values.

## 1.0.0-BETA32

* Added `onChange` method to the PowerSync client. This allows for observing table changes.
Expand Down
91 changes: 91 additions & 0 deletions core/src/commonIntegrationTest/kotlin/com/powersync/CrudTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package com.powersync

import com.powersync.db.schema.Column
import com.powersync.db.schema.IncludeOldOptions
import com.powersync.db.schema.Schema
import com.powersync.db.schema.Table
import com.powersync.testutils.databaseTest
import io.kotest.matchers.shouldBe
import kotlin.test.Test

class CrudTest {
@Test
fun includeMetadata() =
databaseTest {
database.updateSchema(Schema(Table("lists", listOf(Column.text("name")), includeMetadata = true)))

database.execute("INSERT INTO lists (id, name, _metadata) VALUES (uuid(), ?, ?)", listOf("entry", "so meta"))
val batch = database.getNextCrudTransaction()
batch!!.crud[0].metadata shouldBe "so meta"
}

@Test
fun includeOldValues() =
databaseTest {
database.updateSchema(
Schema(Table("lists", listOf(Column.text("name"), Column.text("content")), includeOld = IncludeOldOptions())),
)

database.execute("INSERT INTO lists (id, name, content) VALUES (uuid(), ?, ?)", listOf("entry", "content"))
database.execute("DELETE FROM ps_crud")
database.execute("UPDATE lists SET name = ?", listOf("new name"))

val batch = database.getNextCrudTransaction()
batch!!.crud[0].oldData shouldBe mapOf("name" to "entry", "content" to "content")
}

@Test
fun includeOldValuesWithFilter() =
databaseTest {
database.updateSchema(
Schema(
Table(
"lists",
listOf(Column.text("name"), Column.text("content")),
includeOld = IncludeOldOptions(columnFilter = listOf("name")),
),
),
)

database.execute("INSERT INTO lists (id, name, content) VALUES (uuid(), ?, ?)", listOf("entry", "content"))
database.execute("DELETE FROM ps_crud")
database.execute("UPDATE lists SET name = ?, content = ?", listOf("new name", "new content"))

val batch = database.getNextCrudTransaction()
batch!!.crud[0].oldData shouldBe mapOf("name" to "entry")
}

@Test
fun includeOldValuesWhenChanged() =
databaseTest {
database.updateSchema(
Schema(
Table(
"lists",
listOf(Column.text("name"), Column.text("content")),
includeOld = IncludeOldOptions(onlyWhenChanged = true),
),
),
)

database.execute("INSERT INTO lists (id, name, content) VALUES (uuid(), ?, ?)", listOf("entry", "content"))
database.execute("DELETE FROM ps_crud")
database.execute("UPDATE lists SET name = ?", listOf("new name"))

val batch = database.getNextCrudTransaction()
batch!!.crud[0].oldData shouldBe mapOf("name" to "entry")
}

@Test
fun ignoreEmptyUpdate() =
databaseTest {
database.updateSchema(Schema(Table("lists", listOf(Column.text("name"), Column.text("content")), ignoreEmptyUpdate = true)))

database.execute("INSERT INTO lists (id, name, content) VALUES (uuid(), ?, ?)", listOf("entry", "content"))
database.execute("DELETE FROM ps_crud")
database.execute("UPDATE lists SET name = ?", listOf("entry"))

val batch = database.getNextCrudTransaction()
batch shouldBe null
}
}
7 changes: 7 additions & 0 deletions core/src/commonMain/kotlin/com/powersync/db/crud/CrudEntry.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ public data class CrudEntry(
* This may change in the future.
*/
val transactionId: Int?,
val metadata: String? = null,
/**
* Data associated with the change.
*
Expand All @@ -47,6 +48,7 @@ public data class CrudEntry(
* For DELETE, this is null.
*/
val opData: Map<String, String?>?,
val oldData: Map<String, String?>? = null,
) {
public companion object {
public fun fromRow(row: CrudRow): CrudEntry {
Expand All @@ -61,6 +63,11 @@ public data class CrudEntry(
},
table = data["type"]!!.jsonPrimitive.content,
transactionId = row.txId,
metadata = data["metadata"]?.jsonPrimitive?.content,
oldData =
data["old"]?.jsonObject?.mapValues { (_, value) ->
value.jsonPrimitive.contentOrNull
},
)
}
}
Expand Down
90 changes: 83 additions & 7 deletions core/src/commonMain/kotlin/com/powersync/db/schema/Table.kt
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
package com.powersync.db.schema

import com.powersync.db.crud.CrudEntry
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray

private const val MAX_AMOUNT_OF_COLUMNS = 1999

/**
* A single table in the schema.
*/
public data class Table constructor(
public data class Table(
/**
* The synced table name, matching sync rules.
*/
Expand All @@ -33,6 +37,22 @@ public data class Table constructor(
* Override the name for the view
*/
private val viewNameOverride: String? = null,
/**
* Whether to add a hidden `_metadata` column that will be enabled for updates to attach custom
* information about writes that will be reported through [CrudEntry.metadata].
*/
val includeMetadata: Boolean = false,
/**
* When set to a non-null value, track old values of columns for [CrudEntry.oldData].
*
* See [IncludeOldOptions] for details.
*/
val includeOld: IncludeOldOptions? = null,
/**
* Whether an `UPDATE` statement that doesn't change any values should be ignored when creating
* CRUD entries.
*/
val ignoreEmptyUpdate: Boolean = false,
) {
init {
/**
Expand Down Expand Up @@ -81,6 +101,9 @@ public data class Table constructor(
name: String,
columns: List<Column>,
viewName: String? = null,
ignoreEmptyUpdate: Boolean = false,
includeMetadata: Boolean = false,
includeOld: IncludeOldOptions? = null,
): Table =
Table(
name,
Expand All @@ -89,6 +112,9 @@ public data class Table constructor(
localOnly = false,
insertOnly = true,
viewNameOverride = viewName,
ignoreEmptyUpdate = ignoreEmptyUpdate,
includeMetadata = includeMetadata,
includeOld = includeOld,
)
}

Expand Down Expand Up @@ -135,6 +161,13 @@ public data class Table constructor(
throw AssertionError("Invalid characters in view name: $viewNameOverride")
}

check(!localOnly || !includeMetadata) {
"Can't track metadata for local-only tables."
}
check(!localOnly || includeOld == null) {
"Can't track old values for local-only tables."
}

val columnNames = mutableSetOf("id")
for (column in columns) {
when {
Expand Down Expand Up @@ -185,6 +218,26 @@ public data class Table constructor(
get() = viewNameOverride ?: name
}

/**
* Options to include old values in [CrudEntry.oldData] for update statements.
*
* These options are enabled by passing them to a non-local [Table] constructor.
*/
public data class IncludeOldOptions(
/**
* A filter of column names for which updates should be tracked.
*
* When set to a non-null value, columns not included in this list will not appear in
* [CrudEntry.oldData]. By default, all columns are included.
*/
val columnFilter: List<String>? = null,
/**
* Whether to only include old values when they were changed by an update, instead of always
* including all old values,
*/
val onlyWhenChanged: Boolean = false,
)

@Serializable
internal data class SerializableTable(
var name: String,
Expand All @@ -196,16 +249,39 @@ internal data class SerializableTable(
val insertOnly: Boolean = false,
@SerialName("view_name")
val viewName: String? = null,
@SerialName("ignore_empty_update")
val ignoreEmptyUpdate: Boolean = false,
@SerialName("include_metadata")
val includeMetadata: Boolean = false,
@SerialName("include_old")
val includeOld: JsonElement = JsonPrimitive(false),
@SerialName("include_old_only_when_changed")
val includeOldOnlyWhenChanged: Boolean = false,
)

internal fun Table.toSerializable(): SerializableTable =
with(this) {
SerializableTable(
name,
columns.map { it.toSerializable() },
indexes.map { it.toSerializable() },
localOnly,
insertOnly,
viewName,
name = name,
columns = columns.map { it.toSerializable() },
indexes = indexes.map { it.toSerializable() },
localOnly = localOnly,
insertOnly = insertOnly,
viewName = viewName,
ignoreEmptyUpdate = ignoreEmptyUpdate,
includeMetadata = includeMetadata,
includeOld =
includeOld?.let {
if (it.columnFilter != null) {
buildJsonArray {
for (column in it.columnFilter) {
add(JsonPrimitive(column))
}
}
} else {
JsonPrimitive(true)
}
} ?: JsonPrimitive(false),
includeOldOnlyWhenChanged = includeOld?.onlyWhenChanged ?: false,
)
}
49 changes: 49 additions & 0 deletions core/src/commonTest/kotlin/com/powersync/db/schema/TableTest.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
package com.powersync.db.schema

import com.powersync.utils.JsonUtil
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.serializer
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
Expand Down Expand Up @@ -180,4 +189,44 @@ class TableTest {

assertEquals(exception.message, "users: id column is automatically added, custom id columns are not supported")
}

@Test
fun testValidationLocalOnlyWithMetadata() {
val table = Table("foo", listOf(Column.text("bar")), localOnly = true, includeMetadata = true)

val exception = shouldThrow<IllegalStateException> { table.validate() }
exception.message shouldBe "Can't track metadata for local-only tables."
}

@Test
fun testValidationLocalOnlyWithIncludeOld() {
val table = Table("foo", listOf(Column.text("bar")), localOnly = true, includeOld = IncludeOldOptions())

val exception = shouldThrow<IllegalStateException> { table.validate() }
exception.message shouldBe "Can't track old values for local-only tables."
}

@Test
fun handlesOptions() {
fun serialize(table: Table): JsonObject =
JsonUtil.json.encodeToJsonElement(serializer<SerializableTable>(), table.toSerializable()) as JsonObject

serialize(Table("foo", emptyList(), includeMetadata = true))["include_metadata"]!!.jsonPrimitive.boolean shouldBe true
serialize(Table("foo", emptyList(), ignoreEmptyUpdate = true))["ignore_empty_update"]!!.jsonPrimitive.boolean shouldBe true

serialize(Table("foo", emptyList(), includeOld = IncludeOldOptions())).let {
it["include_old"]!!.jsonPrimitive.boolean shouldBe true
it["include_old_only_when_changed"]!!.jsonPrimitive.boolean shouldBe false
}

serialize(Table("foo", emptyList(), includeOld = IncludeOldOptions(columnFilter = listOf("foo", "bar")))).let {
it["include_old"]!!.jsonArray.map { e -> e.jsonPrimitive.content } shouldBe listOf("foo", "bar")
it["include_old_only_when_changed"]!!.jsonPrimitive.boolean shouldBe false
}

serialize(Table("foo", emptyList(), includeOld = IncludeOldOptions(onlyWhenChanged = true))).let {
it["include_old"]!!.jsonPrimitive.boolean shouldBe true
it["include_old_only_when_changed"]!!.jsonPrimitive.boolean shouldBe true
}
}
}
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ kotlinx-datetime = "0.6.2"
kotlinx-io = "0.5.4"
ktor = "3.0.1"
uuid = "0.8.2"
powersync-core = "0.3.12"
powersync-core = "0.3.13"
sqlite-jdbc = "3.49.1.0"
sqliter = "1.3.1"
turbine = "1.2.0"
Expand Down