Skip to content

Commit 3c234d5

Browse files
Update Schema (#154)
1 parent 91b90e9 commit 3c234d5

File tree

12 files changed

+248
-39
lines changed

12 files changed

+248
-39
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
* Improved concurrent SQLite connection support accross various platforms. All platforms now use a single write connection and multiple read connections for concurrent read queries.
88
* Added the ability to open a SQLite database given a custom `dbDirectory` path. This is currently not supported on iOS due to internal driver restrictions.
99
* Internaly improved the linking of SQLite for iOS.
10+
* Enabled Full Text Search on iOS platforms.
11+
* Added the ability to update the schema for existing PowerSync clients.
12+
* Fixed bug where local only, insert only and view name overrides were not applied for schema tables.
1013
* The Android SQLite driver now uses the [Xerial JDBC library](https://github.com/xerial/sqlite-jdbc). This removes the requirement for users to add the jitpack Maven repository to their projects.
1114
```diff
1215
// settings.gradle.kts example

core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,10 @@ class DatabaseTest {
267267

268268
// Any new readLocks should throw
269269
val exception = assertFailsWith<PowerSyncException> { database.readLock {} }
270-
assertEquals(expected = "Cannot process connection pool request", actual = exception.message)
270+
assertEquals(
271+
expected = "Cannot process connection pool request",
272+
actual = exception.message,
273+
)
271274
// Release the lock
272275
pausedLock.complete(Unit)
273276
lockJob.await()
@@ -344,4 +347,79 @@ class DatabaseTest {
344347
}
345348
assertEquals(expected = 0, actual = count)
346349
}
350+
351+
@Test
352+
fun localOnlyCRUD() =
353+
runTest {
354+
database.updateSchema(
355+
schema =
356+
Schema(
357+
UserRow.table.copy(
358+
// Updating the existing "users" view to localOnly causes an error
359+
// no such table: main.ps_data_local__users.
360+
// Perhaps this is a bug in the core extension
361+
name = "local_users",
362+
localOnly = true,
363+
),
364+
),
365+
)
366+
367+
database.execute(
368+
"""
369+
INSERT INTO
370+
local_users (id, name, email)
371+
VALUES
372+
(uuid(), "one", "[email protected]")
373+
""",
374+
)
375+
376+
val count = database.get("SELECT COUNT(*) FROM local_users") { it.getLong(0)!! }
377+
assertEquals(actual = count, expected = 1)
378+
379+
// No CRUD entries should be present for local only tables
380+
val crudItems = database.getAll("SELECT id from ps_crud") { it.getLong(0)!! }
381+
assertEquals(actual = crudItems.size, expected = 0)
382+
}
383+
384+
@Test
385+
fun insertOnlyCRUD() =
386+
runTest {
387+
database.updateSchema(schema = Schema(UserRow.table.copy(insertOnly = true)))
388+
389+
database.execute(
390+
"""
391+
INSERT INTO
392+
users (id, name, email)
393+
VALUES
394+
(uuid(), "one", "[email protected]")
395+
""",
396+
)
397+
398+
val crudItems = database.getAll("SELECT id from ps_crud") { it.getLong(0)!! }
399+
assertEquals(actual = crudItems.size, expected = 1)
400+
401+
val count = database.get("SELECT COUNT(*) from users") { it.getLong(0)!! }
402+
assertEquals(actual = count, expected = 0)
403+
}
404+
405+
@Test
406+
fun viewOverride() =
407+
runTest {
408+
database.updateSchema(schema = Schema(UserRow.table.copy(viewNameOverride = "people")))
409+
410+
database.execute(
411+
"""
412+
INSERT INTO
413+
people (id, name, email)
414+
VALUES
415+
(uuid(), "one", "[email protected]")
416+
""",
417+
)
418+
419+
val crudItems = database.getAll("SELECT id from ps_crud") { it.getLong(0)!! }
420+
assertEquals(actual = crudItems.size, expected = 1)
421+
422+
val count = database.get("SELECT COUNT(*) from people") { it.getLong(0)!! }
423+
assertEquals(actual = count, expected = 1)
424+
}
347425
}

core/src/commonMain/kotlin/com/powersync/PowerSyncDatabase.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import com.powersync.connectors.PowerSyncBackendConnector
55
import com.powersync.db.Queries
66
import com.powersync.db.crud.CrudBatch
77
import com.powersync.db.crud.CrudTransaction
8+
import com.powersync.db.schema.Schema
89
import com.powersync.sync.SyncStatus
910
import com.powersync.utils.JsonParam
1011
import kotlin.coroutines.cancellation.CancellationException
@@ -36,6 +37,15 @@ public interface PowerSyncDatabase : Queries {
3637
*/
3738
public val currentStatus: SyncStatus
3839

40+
/**
41+
* Replace the schema with a new version. This is for advanced use cases - typically the schema
42+
* should just be specified once in the constructor.
43+
*
44+
* Cannot be used while connected - this should only be called before connect.
45+
*/
46+
@Throws(PowerSyncException::class, CancellationException::class)
47+
public suspend fun updateSchema(schema: Schema)
48+
3949
/**
4050
* Suspend function that resolves when the first sync has occurred
4151
*/

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

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.powersync.db
33
import co.touchlab.kermit.Logger
44
import com.powersync.DatabaseDriverFactory
55
import com.powersync.PowerSyncDatabase
6+
import com.powersync.PowerSyncException
67
import com.powersync.bucket.BucketPriority
78
import com.powersync.bucket.BucketStorage
89
import com.powersync.bucket.BucketStorageImpl
@@ -14,6 +15,7 @@ import com.powersync.db.crud.CrudTransaction
1415
import com.powersync.db.internal.InternalDatabaseImpl
1516
import com.powersync.db.internal.InternalTable
1617
import com.powersync.db.schema.Schema
18+
import com.powersync.db.schema.toSerializable
1719
import com.powersync.sync.PriorityStatusEntry
1820
import com.powersync.sync.SyncStatus
1921
import com.powersync.sync.SyncStatusData
@@ -50,7 +52,7 @@ import kotlinx.serialization.encodeToString
5052
* All changes to local tables are automatically recorded, whether connected or not. Once connected, the changes are uploaded.
5153
*/
5254
internal class PowerSyncDatabaseImpl(
53-
val schema: Schema,
55+
var schema: Schema,
5456
val scope: CoroutineScope,
5557
val factory: DatabaseDriverFactory,
5658
private val dbFilename: String,
@@ -113,21 +115,25 @@ internal class PowerSyncDatabaseImpl(
113115
tx.getOptional("SELECT powersync_init()") {}
114116
}
115117

116-
applySchema()
118+
updateSchema(schema)
117119
updateHasSynced()
118120
}
119121
}
120122

121-
private suspend fun applySchema() {
122-
val schemaJson = JsonUtil.json.encodeToString(schema)
123-
124-
internalDb.writeTransaction { tx ->
125-
tx.getOptional(
126-
"SELECT powersync_replace_schema(?);",
127-
listOf(schemaJson),
128-
) {}
123+
override suspend fun updateSchema(schema: Schema) =
124+
runWrappedSuspending {
125+
mutex.withLock {
126+
if (this.syncStream != null) {
127+
throw PowerSyncException(
128+
"Cannot update schema while connected",
129+
cause = Exception("PowerSync client is already connected"),
130+
)
131+
}
132+
val schemaJson = JsonUtil.json.encodeToString(schema.toSerializable())
133+
internalDb.updateSchema(schemaJson)
134+
this.schema = schema
135+
}
129136
}
130-
}
131137

132138
override suspend fun connect(
133139
connector: PowerSyncBackendConnector,

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,33 @@ internal class ConnectionPool(
5656
}
5757
}
5858

59+
suspend fun <R> withAllConnections(action: suspend (connections: List<TransactorDriver>) -> R): R {
60+
val obtainedConnections = mutableListOf<Pair<TransactorDriver, CompletableDeferred<Unit>>>()
61+
62+
try {
63+
/**
64+
* This attempts to receive (all) the number of available connections.
65+
* This creates a hold for each connection received. This means that subsequent
66+
* receive operations must return unique connections until all the available connections
67+
* have a hold.
68+
*/
69+
repeat(connections.size) {
70+
try {
71+
obtainedConnections.add(available.receive())
72+
} catch (e: PoolClosedException) {
73+
throw PowerSyncException(
74+
message = "Cannot process connection pool request",
75+
cause = e,
76+
)
77+
}
78+
}
79+
80+
return action(obtainedConnections.map { it.first })
81+
} finally {
82+
obtainedConnections.forEach { it.second.complete(Unit) }
83+
}
84+
}
85+
5986
suspend fun close() {
6087
available.cancel(PoolClosedException)
6188
connections.joinAll()

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,7 @@ import kotlinx.coroutines.flow.SharedFlow
66
internal interface InternalDatabase : Queries {
77
fun updatesOnTables(): SharedFlow<Set<String>>
88

9+
suspend fun updateSchema(schemaJson: String): Unit
10+
911
suspend fun close(): Unit
1012
}

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,26 @@ internal class InternalDatabaseImpl(
6666
context.execute(sql, parameters)
6767
}
6868

69+
override suspend fun updateSchema(schemaJson: String) {
70+
withContext(dbContext) {
71+
runWrappedSuspending {
72+
// First get a lock on all read connections
73+
readPool.withAllConnections { readConnections ->
74+
// Then get access to the write connection
75+
writeTransaction { tx ->
76+
tx.getOptional(
77+
"SELECT powersync_replace_schema(?);",
78+
listOf(schemaJson),
79+
) {}
80+
}
81+
82+
// Update the schema on all read connections
83+
readConnections.forEach { it.driver.getAll("pragma table_info('sqlite_master')") {} }
84+
}
85+
}
86+
}
87+
}
88+
6989
override suspend fun <RowType : Any> get(
7090
sql: String,
7191
parameters: List<Any?>?,

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

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,8 @@ package com.powersync.db.schema
33
import kotlinx.serialization.Serializable
44

55
/** A single column in a table schema. */
6-
@Serializable
76
public data class Column(
8-
/** Name of the column. */
97
val name: String,
10-
/** Type of the column.
11-
*
12-
* If the underlying data does not match this type,
13-
* it is cast automatically.
14-
*
15-
* For details on the cast, see:
16-
* https://www.sqlite.org/lang_expr.html#castexpr
17-
*/
188
val type: ColumnType,
199
) {
2010
public companion object {
@@ -28,3 +18,11 @@ public data class Column(
2818
public fun real(name: String): Column = Column(name, ColumnType.REAL)
2919
}
3020
}
21+
22+
@Serializable
23+
internal data class SerializableColumn(
24+
val name: String,
25+
val type: ColumnType,
26+
)
27+
28+
internal fun Column.toSerializable(): SerializableColumn = with(this) { SerializableColumn(name, type) }

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

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

33
import kotlinx.serialization.Serializable
44

5-
@Serializable
65
public data class Index(
76
/**
87
* Descriptive name of the index.
@@ -44,3 +43,17 @@ public data class Index(
4443
return """CREATE INDEX "${fullName(table)}" ON "${table.internalName}"($fields)"""
4544
}
4645
}
46+
47+
@Serializable
48+
internal data class SerializableIndex(
49+
val name: String,
50+
val columns: List<SerializableIndexColumn>,
51+
)
52+
53+
internal fun Index.toSerializable(): SerializableIndex =
54+
with(this) {
55+
SerializableIndex(
56+
name,
57+
columns.map { it.toSerializable() },
58+
)
59+
}

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

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,10 @@ import kotlinx.serialization.Serializable
66
/**
77
* Describes an indexed column.
88
*/
9-
@Serializable
109
public data class IndexedColumn(
11-
/**
12-
* Name of the column to index.
13-
*/
14-
@SerialName("name")
1510
val column: String,
16-
/**
17-
* Whether this column is stored in ascending order in the index.
18-
*/
19-
private val ascending: Boolean = true,
11+
val ascending: Boolean = true,
2012
private var columnDefinition: Column? = null,
21-
/**
22-
* The column definition type
23-
*/
2413
var type: ColumnType? = null,
2514
) {
2615
public companion object {
@@ -46,4 +35,17 @@ public data class IndexedColumn(
4635
}
4736
}
4837

38+
@Serializable
39+
internal data class SerializableIndexColumn(
40+
@SerialName("name")
41+
val column: String,
42+
val type: ColumnType?,
43+
val ascending: Boolean,
44+
)
45+
46+
internal fun IndexedColumn.toSerializable(): SerializableIndexColumn =
47+
with(this) {
48+
SerializableIndexColumn(column, type, ascending)
49+
}
50+
4951
internal fun mapColumn(column: Column): String = "CAST(json_extract(data, ${column.name}) as ${column.type})"

0 commit comments

Comments
 (0)