Skip to content

Commit cf89539

Browse files
committed
Add new schema options
1 parent 664596b commit cf89539

File tree

6 files changed

+216
-8
lines changed

6 files changed

+216
-8
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## unreleased
4+
5+
* Add `includeOld` option on `Table` which sets `CrudEntry.oldData` to previous values on updates.
6+
* Add `includeMetadata` option on `Table` which adds a `_metadata` column that can be used for updates.
7+
The configured metadata is available through `CrudEntry.metadata`.
8+
* Add `ignoreEmptyUpdate` option which skips creating CRUD entries for updates that don't change any values.
9+
310
## 1.0.0-BETA32
411

512
* Added `onChange` method to the PowerSync client. This allows for observing table changes.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package com.powersync
2+
3+
import com.powersync.db.schema.Column
4+
import com.powersync.db.schema.IncludeOldOptions
5+
import com.powersync.db.schema.Schema
6+
import com.powersync.db.schema.Table
7+
import com.powersync.testutils.databaseTest
8+
import io.kotest.matchers.shouldBe
9+
import kotlin.test.Test
10+
11+
class CrudTest {
12+
@Test
13+
fun includeMetadata() = databaseTest {
14+
database.updateSchema(Schema(Table("lists", listOf(Column.text("name")), includeMetadata = true)))
15+
16+
database.execute("INSERT INTO lists (id, name, _metadata) VALUES (uuid(), ?, ?)", listOf("entry", "so meta"))
17+
val batch = database.getNextCrudTransaction()
18+
batch!!.crud[0].metadata shouldBe "so meta"
19+
}
20+
21+
@Test
22+
fun includeOldValues() = databaseTest {
23+
database.updateSchema(Schema(Table("lists", listOf(Column.text("name"), Column.text("content")), includeOld = IncludeOldOptions())))
24+
25+
database.execute("INSERT INTO lists (id, name, content) VALUES (uuid(), ?, ?)", listOf("entry", "content"))
26+
database.execute("DELETE FROM ps_crud")
27+
database.execute("UPDATE lists SET name = ?", listOf("new name"))
28+
29+
val batch = database.getNextCrudTransaction()
30+
batch!!.crud[0].oldData shouldBe mapOf("name" to "entry", "content" to "content")
31+
}
32+
33+
@Test
34+
fun includeOldValuesWithFilter() = databaseTest {
35+
database.updateSchema(Schema(Table("lists", listOf(Column.text("name"), Column.text("content")), includeOld = IncludeOldOptions(columnFilter = listOf("name")))))
36+
37+
database.execute("INSERT INTO lists (id, name, content) VALUES (uuid(), ?, ?)", listOf("entry", "content"))
38+
database.execute("DELETE FROM ps_crud")
39+
database.execute("UPDATE lists SET name = ?, content = ?", listOf("new name", "new content"))
40+
41+
val batch = database.getNextCrudTransaction()
42+
batch!!.crud[0].oldData shouldBe mapOf("name" to "entry")
43+
}
44+
45+
@Test
46+
fun includeOldValuesWhenChanged() = databaseTest {
47+
database.updateSchema(Schema(Table("lists", listOf(Column.text("name"), Column.text("content")), includeOld = IncludeOldOptions(onlyWhenChanged = true))))
48+
49+
database.execute("INSERT INTO lists (id, name, content) VALUES (uuid(), ?, ?)", listOf("entry", "content"))
50+
database.execute("DELETE FROM ps_crud")
51+
database.execute("UPDATE lists SET name = ?", listOf("new name"))
52+
53+
val batch = database.getNextCrudTransaction()
54+
batch!!.crud[0].oldData shouldBe mapOf("name" to "entry")
55+
}
56+
57+
@Test
58+
fun ignoreEmptyUpdate() = databaseTest {
59+
database.updateSchema(Schema(Table("lists", listOf(Column.text("name"), Column.text("content")), ignoreEmptyUpdate = true)))
60+
61+
database.execute("INSERT INTO lists (id, name, content) VALUES (uuid(), ?, ?)", listOf("entry", "content"))
62+
database.execute("DELETE FROM ps_crud")
63+
database.execute("UPDATE lists SET name = ?", listOf("entry"))
64+
65+
val batch = database.getNextCrudTransaction()
66+
batch shouldBe null
67+
}
68+
}

core/src/commonMain/kotlin/com/powersync/db/crud/CrudEntry.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ public data class CrudEntry(
3737
* This may change in the future.
3838
*/
3939
val transactionId: Int?,
40+
val metadata: String? = null,
4041
/**
4142
* Data associated with the change.
4243
*
@@ -47,6 +48,7 @@ public data class CrudEntry(
4748
* For DELETE, this is null.
4849
*/
4950
val opData: Map<String, String?>?,
51+
val oldData: Map<String, String?>? = null,
5052
) {
5153
public companion object {
5254
public fun fromRow(row: CrudRow): CrudEntry {
@@ -61,6 +63,10 @@ public data class CrudEntry(
6163
},
6264
table = data["type"]!!.jsonPrimitive.content,
6365
transactionId = row.txId,
66+
metadata = data["metadata"]?.jsonPrimitive?.content,
67+
oldData = data["old"]?.jsonObject?.mapValues { (_, value) ->
68+
value.jsonPrimitive.contentOrNull
69+
}
6470
)
6571
}
6672
}

core/src/commonMain/kotlin/com/powersync/db/schema/Table.kt

Lines changed: 83 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
package com.powersync.db.schema
22

3+
import com.powersync.db.crud.CrudEntry
34
import kotlinx.serialization.SerialName
45
import kotlinx.serialization.Serializable
6+
import kotlinx.serialization.json.JsonElement
7+
import kotlinx.serialization.json.JsonPrimitive
8+
import kotlinx.serialization.json.buildJsonArray
9+
510

611
private const val MAX_AMOUNT_OF_COLUMNS = 1999
712

813
/**
914
* A single table in the schema.
1015
*/
11-
public data class Table constructor(
16+
public data class Table (
1217
/**
1318
* The synced table name, matching sync rules.
1419
*/
@@ -33,6 +38,22 @@ public data class Table constructor(
3338
* Override the name for the view
3439
*/
3540
private val viewNameOverride: String? = null,
41+
/**
42+
* Whether to add a hidden `_metadata` column that will be enabled for updates to attach custom
43+
* information about writes that will be reported through [CrudEntry.metadata].
44+
*/
45+
val includeMetadata: Boolean = false,
46+
/**
47+
* When set to a non-null value, track old values of columns for [CrudEntry.oldData].
48+
*
49+
* See [IncludeOldOptions] for details.
50+
*/
51+
val includeOld: IncludeOldOptions? = null,
52+
/**
53+
* Whether an `UPDATE` statement that doesn't change any values should be ignored when creating
54+
* CRUD entries.
55+
*/
56+
val ignoreEmptyUpdate: Boolean = false,
3657
) {
3758
init {
3859
/**
@@ -81,6 +102,9 @@ public data class Table constructor(
81102
name: String,
82103
columns: List<Column>,
83104
viewName: String? = null,
105+
ignoreEmptyUpdate: Boolean = false,
106+
includeMetadata: Boolean = false,
107+
includeOld: IncludeOldOptions? = null,
84108
): Table =
85109
Table(
86110
name,
@@ -89,6 +113,9 @@ public data class Table constructor(
89113
localOnly = false,
90114
insertOnly = true,
91115
viewNameOverride = viewName,
116+
ignoreEmptyUpdate = ignoreEmptyUpdate,
117+
includeMetadata = includeMetadata,
118+
includeOld = includeOld
92119
)
93120
}
94121

@@ -135,6 +162,13 @@ public data class Table constructor(
135162
throw AssertionError("Invalid characters in view name: $viewNameOverride")
136163
}
137164

165+
check(!localOnly || !includeMetadata) {
166+
"Can't track metadata for local-only tables."
167+
}
168+
check(!localOnly || includeOld == null) {
169+
"Can't track old values for local-only tables."
170+
}
171+
138172
val columnNames = mutableSetOf("id")
139173
for (column in columns) {
140174
when {
@@ -185,6 +219,26 @@ public data class Table constructor(
185219
get() = viewNameOverride ?: name
186220
}
187221

222+
/**
223+
* Options to include old values in [CrudEntry.oldData] for update statements.
224+
*
225+
* These options are enabled by passing them to a non-local [Table] constructor.
226+
*/
227+
public data class IncludeOldOptions(
228+
/**
229+
* A filter of column names for which updates should be tracked.
230+
*
231+
* When set to a non-null value, columns not included in this list will not appear in
232+
* [CrudEntry.oldData]. By default, all columns are included.
233+
*/
234+
val columnFilter: List<String>? = null,
235+
/**
236+
* Whether to only include old values when they were changed by an update, instead of always
237+
* including all old values,
238+
*/
239+
val onlyWhenChanged: Boolean = false,
240+
)
241+
188242
@Serializable
189243
internal data class SerializableTable(
190244
var name: String,
@@ -196,16 +250,38 @@ internal data class SerializableTable(
196250
val insertOnly: Boolean = false,
197251
@SerialName("view_name")
198252
val viewName: String? = null,
253+
@SerialName("ignore_empty_update")
254+
val ignoreEmptyUpdate: Boolean = false,
255+
@SerialName("include_metadata")
256+
val includeMetadata: Boolean = false,
257+
@SerialName("include_old")
258+
val includeOld: JsonElement = JsonPrimitive(false),
259+
@SerialName("include_old_only_when_changed")
260+
val includeOldOnlyWhenChanged: Boolean = false
199261
)
200262

201263
internal fun Table.toSerializable(): SerializableTable =
202264
with(this) {
203265
SerializableTable(
204-
name,
205-
columns.map { it.toSerializable() },
206-
indexes.map { it.toSerializable() },
207-
localOnly,
208-
insertOnly,
209-
viewName,
266+
name=name,
267+
columns=columns.map { it.toSerializable() },
268+
indexes=indexes.map { it.toSerializable() },
269+
localOnly=localOnly,
270+
insertOnly=insertOnly,
271+
viewName=viewName,
272+
ignoreEmptyUpdate = ignoreEmptyUpdate,
273+
includeMetadata = includeMetadata,
274+
includeOld = includeOld?.let {
275+
if (it.columnFilter != null) {
276+
buildJsonArray {
277+
for (column in it.columnFilter) {
278+
add(JsonPrimitive(column))
279+
}
280+
}
281+
} else {
282+
JsonPrimitive(true)
283+
}
284+
} ?: JsonPrimitive(false),
285+
includeOldOnlyWhenChanged = includeOld?.onlyWhenChanged ?: false
210286
)
211287
}

core/src/commonTest/kotlin/com/powersync/db/schema/TableTest.kt

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
package com.powersync.db.schema
22

3+
import com.powersync.utils.JsonUtil
4+
import io.kotest.assertions.throwables.shouldThrow
5+
import io.kotest.matchers.shouldBe
6+
import kotlinx.serialization.json.JsonElement
7+
import kotlinx.serialization.json.JsonObject
8+
import kotlinx.serialization.json.boolean
9+
import kotlinx.serialization.json.encodeToJsonElement
10+
import kotlinx.serialization.json.jsonArray
11+
import kotlinx.serialization.json.jsonPrimitive
12+
import kotlinx.serialization.serializer
313
import kotlin.test.Test
414
import kotlin.test.assertEquals
515
import kotlin.test.assertFailsWith
@@ -180,4 +190,45 @@ class TableTest {
180190

181191
assertEquals(exception.message, "users: id column is automatically added, custom id columns are not supported")
182192
}
193+
194+
@Test
195+
fun testValidationLocalOnlyWithMetadata() {
196+
val table = Table("foo", listOf(Column.text("bar")), localOnly = true, includeMetadata = true)
197+
198+
val exception = shouldThrow<IllegalStateException> { table.validate() }
199+
exception.message shouldBe "Can't track metadata for local-only tables."
200+
}
201+
202+
@Test
203+
fun testValidationLocalOnlyWithIncludeOld() {
204+
val table = Table("foo", listOf(Column.text("bar")), localOnly = true, includeOld = IncludeOldOptions())
205+
206+
val exception = shouldThrow<IllegalStateException> { table.validate() }
207+
exception.message shouldBe "Can't track old values for local-only tables."
208+
}
209+
210+
@Test
211+
fun handlesOptions() {
212+
fun serialize(table: Table): JsonObject {
213+
return JsonUtil.json.encodeToJsonElement(serializer<SerializableTable>(), table.toSerializable()) as JsonObject
214+
}
215+
216+
serialize(Table("foo", emptyList(), includeMetadata = true))["include_metadata"]!!.jsonPrimitive.boolean shouldBe true
217+
serialize(Table("foo", emptyList(), ignoreEmptyUpdate = true))["ignore_empty_update"]!!.jsonPrimitive.boolean shouldBe true
218+
219+
serialize(Table("foo", emptyList(), includeOld = IncludeOldOptions())).let {
220+
it["include_old"]!!.jsonPrimitive.boolean shouldBe true
221+
it["include_old_only_when_changed"]!!.jsonPrimitive.boolean shouldBe false
222+
}
223+
224+
serialize(Table("foo", emptyList(), includeOld = IncludeOldOptions(columnFilter = listOf("foo", "bar")))).let {
225+
it["include_old"]!!.jsonArray.map { e -> e.jsonPrimitive.content } shouldBe listOf("foo", "bar")
226+
it["include_old_only_when_changed"]!!.jsonPrimitive.boolean shouldBe false
227+
}
228+
229+
serialize(Table("foo", emptyList(), includeOld = IncludeOldOptions(onlyWhenChanged = true))).let {
230+
it["include_old"]!!.jsonPrimitive.boolean shouldBe true
231+
it["include_old_only_when_changed"]!!.jsonPrimitive.boolean shouldBe true
232+
}
233+
}
183234
}

gradle/libs.versions.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ kotlinx-datetime = "0.6.2"
1717
kotlinx-io = "0.5.4"
1818
ktor = "3.0.1"
1919
uuid = "0.8.2"
20-
powersync-core = "0.3.12"
20+
powersync-core = "0.3.13"
2121
sqlite-jdbc = "3.49.1.0"
2222
sqliter = "1.3.1"
2323
turbine = "1.2.0"

0 commit comments

Comments
 (0)