Skip to content

Commit bc6cc85

Browse files
committed
Support raw tables
1 parent dfe2620 commit bc6cc85

File tree

12 files changed

+236
-15
lines changed

12 files changed

+236
-15
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
# Changelog
22

3-
## 1.2.3 (unreleased)
3+
## 1.3.0 (unreleased)
44

5+
* Support tables created outside of PowerSync with the `RawTable` API.
56
* Fix `runWrapped` catching cancellation exceptions.
67

78
## 1.2.2

core/src/commonIntegrationTest/kotlin/com/powersync/sync/SyncIntegrationTest.kt

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.powersync.sync
22

33
import app.cash.turbine.turbineScope
44
import co.touchlab.kermit.ExperimentalKermitApi
5+
import com.powersync.ExperimentalPowerSyncAPI
56
import com.powersync.PowerSyncDatabase
67
import com.powersync.PowerSyncException
78
import com.powersync.TestConnector
@@ -13,6 +14,9 @@ import com.powersync.bucket.OplogEntry
1314
import com.powersync.bucket.WriteCheckpointData
1415
import com.powersync.bucket.WriteCheckpointResponse
1516
import com.powersync.db.PowerSyncDatabaseImpl
17+
import com.powersync.db.schema.PendingStatement
18+
import com.powersync.db.schema.PendingStatementParameter
19+
import com.powersync.db.schema.RawTable
1620
import com.powersync.db.schema.Schema
1721
import com.powersync.testutils.UserRow
1822
import com.powersync.testutils.databaseTest
@@ -650,7 +654,7 @@ abstract class BaseSyncIntegrationTest(
650654
class LegacySyncIntegrationTest : BaseSyncIntegrationTest(false)
651655

652656
class NewSyncIntegrationTest : BaseSyncIntegrationTest(true) {
653-
// The legacy sync implementation doesn't prefetch credentials.
657+
// The legacy sync implementation doesn't prefetch credentials and doesn't support raw tables.
654658

655659
@OptIn(LegacySyncImplementation::class)
656660
@Test
@@ -688,4 +692,88 @@ class NewSyncIntegrationTest : BaseSyncIntegrationTest(true) {
688692
turbine.cancel()
689693
}
690694
}
695+
696+
@Test
697+
@OptIn(ExperimentalPowerSyncAPI::class, LegacySyncImplementation::class)
698+
fun rawTables() = databaseTest(createInitialDatabase = false) {
699+
val db = openDatabase(Schema(listOf(
700+
RawTable(
701+
name = "lists",
702+
put = PendingStatement(
703+
"INSERT OR REPLACE INTO lists (id, name) VALUES (?, ?)",
704+
listOf(PendingStatementParameter.Id, PendingStatementParameter.Column("name"))
705+
),
706+
delete = PendingStatement(
707+
"DELETE FROM lists WHERE id = ?", listOf(PendingStatementParameter.Id)
708+
)
709+
)
710+
)))
711+
712+
db.execute("CREATE TABLE lists (id TEXT NOT NULL PRIMARY KEY, name TEXT)")
713+
turbineScope(timeout = 10.0.seconds) {
714+
val query = db.watch("SELECT * FROM lists", throttleMs = 0L) {
715+
it.getString(0) to it.getString(1)
716+
}.testIn(this)
717+
query.awaitItem() shouldBe emptyList()
718+
719+
db.connect(connector, options = options)
720+
syncLines.send(SyncLine.FullCheckpoint(Checkpoint(
721+
lastOpId = "1",
722+
checksums = listOf(BucketChecksum("a", checksum = 0)),
723+
)))
724+
syncLines.send(
725+
SyncLine.SyncDataBucket(
726+
bucket = "a",
727+
data =
728+
listOf(
729+
OplogEntry(
730+
checksum = 0L,
731+
data =
732+
JsonUtil.json.encodeToString(
733+
mapOf(
734+
"name" to "custom list",
735+
),
736+
),
737+
op = OpType.PUT,
738+
opId = "1",
739+
rowId = "my_list",
740+
rowType = "lists",
741+
),
742+
),
743+
after = null,
744+
nextAfter = null,
745+
),
746+
)
747+
syncLines.send(SyncLine.CheckpointComplete("1"))
748+
749+
query.awaitItem() shouldBe listOf("my_list" to "custom list")
750+
751+
syncLines.send(SyncLine.FullCheckpoint(Checkpoint(
752+
lastOpId = "2",
753+
checksums = listOf(BucketChecksum("a", checksum = 0)),
754+
)))
755+
syncLines.send(
756+
SyncLine.SyncDataBucket(
757+
bucket = "a",
758+
data =
759+
listOf(
760+
OplogEntry(
761+
checksum = 0L,
762+
data = null,
763+
op = OpType.REMOVE,
764+
opId = "2",
765+
rowId = "my_list",
766+
rowType = "lists",
767+
),
768+
),
769+
after = null,
770+
nextAfter = null,
771+
),
772+
)
773+
syncLines.send(SyncLine.CheckpointComplete("1"))
774+
775+
query.awaitItem() shouldBe emptyList()
776+
query.cancelAndIgnoreRemainingEvents()
777+
}
778+
}
691779
}

core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,12 +114,12 @@ internal class ActiveDatabaseTest(
114114
everySuspend { invalidateCredentials() } returns Unit
115115
}
116116

117-
fun openDatabase(): PowerSyncDatabaseImpl {
117+
fun openDatabase(schema: Schema = Schema(UserRow.table)): PowerSyncDatabaseImpl {
118118
logger.d { "Opening database $databaseName in directory $testDirectory" }
119119
val db =
120120
createPowerSyncDatabaseImpl(
121121
factory = factory,
122-
schema = Schema(UserRow.table),
122+
schema = schema,
123123
dbFilename = databaseName,
124124
dbDirectory = testDirectory,
125125
logger = logger,

core/src/commonMain/kotlin/com/powersync/bucket/BucketStorage.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.powersync.bucket
22

33
import com.powersync.db.crud.CrudEntry
44
import com.powersync.db.internal.PowerSyncTransaction
5+
import com.powersync.db.schema.SerializableSchema
56
import com.powersync.sync.Instruction
67
import com.powersync.sync.LegacySyncImplementation
78
import com.powersync.sync.SyncDataBatch
@@ -59,6 +60,7 @@ internal sealed interface PowerSyncControlArguments {
5960
@Serializable
6061
class Start(
6162
val parameters: JsonObject,
63+
val schema: SerializableSchema
6264
) : PowerSyncControlArguments
6365

6466
data object Stop : PowerSyncControlArguments

core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ internal class PowerSyncDatabaseImpl(
169169
uploadScope = scope,
170170
createClient = createClient,
171171
options = options,
172+
schema = schema,
172173
)
173174
}
174175
}

core/src/commonMain/kotlin/com/powersync/db/internal/InternalDatabaseImpl.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ import kotlinx.coroutines.sync.withLock
2525
import kotlinx.coroutines.withContext
2626
import kotlin.time.Duration.Companion.milliseconds
2727

28-
@OptIn(FlowPreview::class)
2928
internal class InternalDatabaseImpl(
3029
private val factory: DatabaseDriverFactory,
3130
private val scope: CoroutineScope,
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.powersync.db.schema
2+
3+
public sealed interface BaseTable {
4+
/**
5+
* The name of the table.
6+
*/
7+
public val name: String
8+
9+
/**
10+
* Check that there are no issues in the table definition.
11+
*/
12+
public fun validate()
13+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package com.powersync.db.schema
2+
3+
import com.powersync.ExperimentalPowerSyncAPI
4+
import com.powersync.sync.SyncOptions
5+
import kotlinx.serialization.Serializable
6+
import kotlinx.serialization.json.JsonElement
7+
import kotlinx.serialization.json.JsonObjectBuilder
8+
import kotlinx.serialization.json.JsonPrimitive
9+
import kotlinx.serialization.json.buildJsonArray
10+
import kotlinx.serialization.json.buildJsonObject
11+
import kotlinx.serialization.json.put
12+
13+
/**
14+
* A table that is managed by the user instead of being auto-created and migrated by the PowerSync
15+
* SDK.
16+
*
17+
* These tables give application developers full control over the table (including table and
18+
* column constraints). The [put] and [delete] statements the sync client uses to apply operations
19+
* to the local database also need to be set explicitly.
20+
*
21+
* A main benefit of raw tables is that, since they're not backed by JSON views, complex queries on
22+
* them can be much more efficient.
23+
*
24+
* Note that raw tables are only supported when [SyncOptions.newClientImplementation] is enabled.
25+
*/
26+
@ExperimentalPowerSyncAPI
27+
public class RawTable(
28+
override val name: String,
29+
public val put: PendingStatement,
30+
/**
31+
* The statement to run when the sync client wants to delete a row.
32+
*/
33+
public val delete: PendingStatement,
34+
): BaseTable {
35+
override fun validate() {
36+
// We don't currently have any validation for raw tables
37+
}
38+
39+
internal fun serialize(): JsonElement {
40+
return buildJsonObject {
41+
put("name", name)
42+
put("put", put.serialize())
43+
put("delete", delete.serialize())
44+
}
45+
}
46+
}
47+
48+
@ExperimentalPowerSyncAPI
49+
public class PendingStatement(
50+
public val sql: String,
51+
public val parameters: List<PendingStatementParameter>,
52+
) {
53+
internal fun serialize(): JsonElement {
54+
return buildJsonObject {
55+
put("sql", sql)
56+
put("params", buildJsonArray {
57+
for (param in parameters) {
58+
add(when(param) {
59+
is PendingStatementParameter.Column -> buildJsonObject {
60+
put("Column", param.name)
61+
}
62+
PendingStatementParameter.Id -> JsonPrimitive("Id")
63+
})
64+
}
65+
})
66+
}
67+
}
68+
}
69+
70+
/**
71+
* A parameter that can be used in a [PendingStatement].
72+
*/
73+
@ExperimentalPowerSyncAPI
74+
public sealed interface PendingStatementParameter {
75+
/**
76+
* Resolves to the id of the affected row.
77+
*/
78+
public object Id: PendingStatementParameter
79+
80+
/**
81+
* Resolves to the value of a column in the added row.
82+
*
83+
* This is only available for [RawTable.put] - in [RawTable.delete] statements, only the [Id]
84+
* can be used as a value.
85+
*/
86+
public class Column(public val name: String): PendingStatementParameter
87+
}
88+

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

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,35 @@
11
package com.powersync.db.schema
22

3+
import com.powersync.ExperimentalPowerSyncAPI
4+
import kotlinx.serialization.SerialName
35
import kotlinx.serialization.Serializable
6+
import kotlinx.serialization.json.JsonElement
47

58
/**
69
* The schema used by the database.
710
*
811
* The implementation uses the schema as a "VIEW" on top of JSON data.
912
* No migrations are required on the client.
1013
*/
11-
public data class Schema(
14+
@OptIn(ExperimentalPowerSyncAPI::class)
15+
public data class Schema internal constructor(
1216
val tables: List<Table>,
17+
val rawTables: List<RawTable>,
1318
) {
19+
public constructor(tables: List<BaseTable>): this(
20+
tables.filterIsInstance<Table>(),
21+
tables.filterIsInstance<RawTable>()
22+
)
23+
1424
init {
1525
validate()
1626
}
1727

28+
internal val allTables: Sequence<BaseTable> get() = sequence {
29+
yieldAll(tables)
30+
yieldAll(rawTables)
31+
}
32+
1833
/**
1934
* Secondary constructor to create a schema with a variable number of tables.
2035
*/
@@ -28,7 +43,7 @@ public data class Schema(
2843
*/
2944
public fun validate() {
3045
val tableNames = mutableSetOf<String>()
31-
tables.forEach { table ->
46+
allTables.forEach { table ->
3247
if (!tableNames.add(table.name)) {
3348
throw AssertionError("Duplicate table name: ${table.name}")
3449
}
@@ -56,9 +71,15 @@ public data class Schema(
5671
@Serializable
5772
internal data class SerializableSchema(
5873
val tables: List<SerializableTable>,
74+
@SerialName("raw_tables")
75+
val rawTables: List<JsonElement>,
5976
)
6077

78+
@OptIn(ExperimentalPowerSyncAPI::class)
6179
internal fun Schema.toSerializable(): SerializableSchema =
6280
with(this) {
63-
SerializableSchema(tables.map { it.toSerializable() })
81+
SerializableSchema(
82+
tables = tables.map { it.toSerializable() },
83+
rawTables = rawTables.map { it.serialize() }
84+
)
6485
}

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

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ public data class Table(
1616
/**
1717
* The synced table name, matching sync rules.
1818
*/
19-
var name: String,
19+
override var name: String,
2020
/**
2121
* List of columns.
2222
*/
@@ -53,7 +53,7 @@ public data class Table(
5353
* CRUD entries.
5454
*/
5555
val ignoreEmptyUpdates: Boolean = false,
56-
) {
56+
): BaseTable {
5757
init {
5858
/**
5959
* Need to set the column definition for each index column.
@@ -141,10 +141,7 @@ public data class Table(
141141
)
142142
)
143143

144-
/**
145-
* Check that there are no issues in the table definition.
146-
*/
147-
public fun validate() {
144+
public override fun validate() {
148145
if (columns.size > MAX_AMOUNT_OF_COLUMNS) {
149146
throw AssertionError("Table $name has more than $MAX_AMOUNT_OF_COLUMNS columns, which is not supported")
150147
}

0 commit comments

Comments
 (0)