Skip to content

Commit cb37440

Browse files
authored
Merge pull request #181 from powersync-ja/schema-options
Add new schema options
2 parents 5a22850 + bb05e43 commit cb37440

File tree

5 files changed

+255
-7
lines changed

5 files changed

+255
-7
lines changed

CHANGELOG.md

+7
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## unreleased
4+
5+
* Add `trackPreviousValues` option on `Table` which sets `CrudEntry.oldData` to previous values on updates.
6+
* Add `trackMetadata` 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 `ignoreEmptyUpdates` option which skips creating CRUD entries for updates that don't change any values.
9+
310
## 1.0.1
411

512
* [Internal] Version bump for broken Swift release pipeline
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package com.powersync
2+
3+
import com.powersync.db.schema.Column
4+
import com.powersync.db.schema.Schema
5+
import com.powersync.db.schema.Table
6+
import com.powersync.db.schema.TrackPreviousValuesOptions
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() =
14+
databaseTest {
15+
database.updateSchema(Schema(Table("lists", listOf(Column.text("name")), trackMetadata = true)))
16+
17+
database.execute("INSERT INTO lists (id, name, _metadata) VALUES (uuid(), ?, ?)", listOf("entry", "so meta"))
18+
val batch = database.getNextCrudTransaction()
19+
batch!!.crud[0].metadata shouldBe "so meta"
20+
}
21+
22+
@Test
23+
fun includeOldValues() =
24+
databaseTest {
25+
database.updateSchema(
26+
Schema(
27+
Table("lists", listOf(Column.text("name"), Column.text("content")), trackPreviousValues = TrackPreviousValuesOptions()),
28+
),
29+
)
30+
31+
database.execute("INSERT INTO lists (id, name, content) VALUES (uuid(), ?, ?)", listOf("entry", "content"))
32+
database.execute("DELETE FROM ps_crud")
33+
database.execute("UPDATE lists SET name = ?", listOf("new name"))
34+
35+
val batch = database.getNextCrudTransaction()
36+
batch!!.crud[0].oldData shouldBe mapOf("name" to "entry", "content" to "content")
37+
}
38+
39+
@Test
40+
fun includeOldValuesWithFilter() =
41+
databaseTest {
42+
database.updateSchema(
43+
Schema(
44+
Table(
45+
"lists",
46+
listOf(Column.text("name"), Column.text("content")),
47+
trackPreviousValues = TrackPreviousValuesOptions(columnFilter = listOf("name")),
48+
),
49+
),
50+
)
51+
52+
database.execute("INSERT INTO lists (id, name, content) VALUES (uuid(), ?, ?)", listOf("entry", "content"))
53+
database.execute("DELETE FROM ps_crud")
54+
database.execute("UPDATE lists SET name = ?, content = ?", listOf("new name", "new content"))
55+
56+
val batch = database.getNextCrudTransaction()
57+
batch!!.crud[0].oldData shouldBe mapOf("name" to "entry")
58+
}
59+
60+
@Test
61+
fun includeOldValuesWhenChanged() =
62+
databaseTest {
63+
database.updateSchema(
64+
Schema(
65+
Table(
66+
"lists",
67+
listOf(Column.text("name"), Column.text("content")),
68+
trackPreviousValues = TrackPreviousValuesOptions(onlyWhenChanged = true),
69+
),
70+
),
71+
)
72+
73+
database.execute("INSERT INTO lists (id, name, content) VALUES (uuid(), ?, ?)", listOf("entry", "content"))
74+
database.execute("DELETE FROM ps_crud")
75+
database.execute("UPDATE lists SET name = ?", listOf("new name"))
76+
77+
val batch = database.getNextCrudTransaction()
78+
batch!!.crud[0].oldData shouldBe mapOf("name" to "entry")
79+
}
80+
81+
@Test
82+
fun ignoreEmptyUpdate() =
83+
databaseTest {
84+
database.updateSchema(Schema(Table("lists", listOf(Column.text("name"), Column.text("content")), ignoreEmptyUpdates = true)))
85+
86+
database.execute("INSERT INTO lists (id, name, content) VALUES (uuid(), ?, ?)", listOf("entry", "content"))
87+
database.execute("DELETE FROM ps_crud")
88+
database.execute("UPDATE lists SET name = ?", listOf("entry"))
89+
90+
val batch = database.getNextCrudTransaction()
91+
batch shouldBe null
92+
}
93+
}

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

+23
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.powersync.db.crud
22

3+
import com.powersync.PowerSyncDatabase
4+
import com.powersync.db.schema.Table
35
import com.powersync.utils.JsonUtil
46
import kotlinx.serialization.json.contentOrNull
57
import kotlinx.serialization.json.jsonObject
@@ -37,6 +39,15 @@ public data class CrudEntry(
3739
* This may change in the future.
3840
*/
3941
val transactionId: Int?,
42+
/**
43+
* User-defined metadata that can be attached to writes.
44+
*
45+
* This is the value the `_metadata` column had when the write to the database was made,
46+
* allowing backend connectors to e.g. identify a write and treat it specially.
47+
*
48+
* Note that the `_metadata` column is only available when [Table.trackMetadata] is enabled.
49+
*/
50+
val metadata: String? = null,
4051
/**
4152
* Data associated with the change.
4253
*
@@ -47,6 +58,13 @@ public data class CrudEntry(
4758
* For DELETE, this is null.
4859
*/
4960
val opData: Map<String, String?>?,
61+
/**
62+
* Previous values before this change.
63+
*
64+
* These values can be tracked for `UPDATE` statements when [Table.trackPreviousValues] is
65+
* enabled.
66+
*/
67+
val oldData: Map<String, String?>? = null,
5068
) {
5169
public companion object {
5270
public fun fromRow(row: CrudRow): CrudEntry {
@@ -61,6 +79,11 @@ public data class CrudEntry(
6179
},
6280
table = data["type"]!!.jsonPrimitive.content,
6381
transactionId = row.txId,
82+
metadata = data["metadata"]?.jsonPrimitive?.content,
83+
oldData =
84+
data["old"]?.jsonObject?.mapValues { (_, value) ->
85+
value.jsonPrimitive.contentOrNull
86+
},
6487
)
6588
}
6689
}

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

+83-7
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
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
59

610
private const val MAX_AMOUNT_OF_COLUMNS = 1999
711

812
/**
913
* A single table in the schema.
1014
*/
11-
public data class Table constructor(
15+
public data class Table(
1216
/**
1317
* The synced table name, matching sync rules.
1418
*/
@@ -33,6 +37,22 @@ public data class Table constructor(
3337
* Override the name for the view
3438
*/
3539
private val viewNameOverride: String? = null,
40+
/**
41+
* Whether to add a hidden `_metadata` column that will be enabled for updates to attach custom
42+
* information about writes that will be reported through [CrudEntry.metadata].
43+
*/
44+
val trackMetadata: Boolean = false,
45+
/**
46+
* When set to a non-null value, track old values of columns for [CrudEntry.oldData].
47+
*
48+
* See [TrackPreviousValuesOptions] for details.
49+
*/
50+
val trackPreviousValues: TrackPreviousValuesOptions? = null,
51+
/**
52+
* Whether an `UPDATE` statement that doesn't change any values should be ignored when creating
53+
* CRUD entries.
54+
*/
55+
val ignoreEmptyUpdates: Boolean = false,
3656
) {
3757
init {
3858
/**
@@ -81,6 +101,9 @@ public data class Table constructor(
81101
name: String,
82102
columns: List<Column>,
83103
viewName: String? = null,
104+
ignoreEmptyUpdates: Boolean = false,
105+
trackMetadata: Boolean = false,
106+
trackPreviousValues: TrackPreviousValuesOptions? = null,
84107
): Table =
85108
Table(
86109
name,
@@ -89,6 +112,9 @@ public data class Table constructor(
89112
localOnly = false,
90113
insertOnly = true,
91114
viewNameOverride = viewName,
115+
ignoreEmptyUpdates = ignoreEmptyUpdates,
116+
trackMetadata = trackMetadata,
117+
trackPreviousValues = trackPreviousValues,
92118
)
93119
}
94120

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

164+
check(!localOnly || !trackMetadata) {
165+
"Can't track metadata for local-only tables."
166+
}
167+
check(!localOnly || trackPreviousValues == null) {
168+
"Can't track old values for local-only tables."
169+
}
170+
138171
val columnNames = mutableSetOf("id")
139172
for (column in columns) {
140173
when {
@@ -185,6 +218,26 @@ public data class Table constructor(
185218
get() = viewNameOverride ?: name
186219
}
187220

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

201262
internal fun Table.toSerializable(): SerializableTable =
202263
with(this) {
203264
SerializableTable(
204-
name,
205-
columns.map { it.toSerializable() },
206-
indexes.map { it.toSerializable() },
207-
localOnly,
208-
insertOnly,
209-
viewName,
265+
name = name,
266+
columns = columns.map { it.toSerializable() },
267+
indexes = indexes.map { it.toSerializable() },
268+
localOnly = localOnly,
269+
insertOnly = insertOnly,
270+
viewName = viewName,
271+
ignoreEmptyUpdate = ignoreEmptyUpdates,
272+
includeMetadata = trackMetadata,
273+
includeOld =
274+
trackPreviousValues?.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 = trackPreviousValues?.onlyWhenChanged ?: false,
210286
)
211287
}

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

+49
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
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.JsonObject
7+
import kotlinx.serialization.json.boolean
8+
import kotlinx.serialization.json.encodeToJsonElement
9+
import kotlinx.serialization.json.jsonArray
10+
import kotlinx.serialization.json.jsonPrimitive
11+
import kotlinx.serialization.serializer
312
import kotlin.test.Test
413
import kotlin.test.assertEquals
514
import kotlin.test.assertFailsWith
@@ -180,4 +189,44 @@ class TableTest {
180189

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

0 commit comments

Comments
 (0)