Skip to content

Commit 1778b8f

Browse files
authored
Implement schema serialization in Swift (#129)
1 parent c4f8126 commit 1778b8f

9 files changed

Lines changed: 375 additions & 20 deletions

File tree

.github/workflows/build_and_test.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ jobs:
88
build:
99
name: Build and test
1010
runs-on: macos-latest
11+
timeout-minutes: 30
1112
steps:
1213
- uses: actions/checkout@v4
1314
- name: Set up XCode

Sources/PowerSync/Protocol/Schema/Column.swift

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,26 @@ public protocol ColumnProtocol: Equatable, Sendable {
1515
var type: ColumnData { get }
1616
}
1717

18-
public enum ColumnData: Sendable {
18+
public enum ColumnData: Sendable, Encodable {
1919
case text
2020
case integer
2121
case real
22+
23+
public func encode(to encoder: any Encoder) throws {
24+
var container = encoder.singleValueContainer()
25+
switch self {
26+
case .text:
27+
try container.encode("text")
28+
case .integer:
29+
try container.encode("integer")
30+
case .real:
31+
try container.encode("real")
32+
}
33+
}
2234
}
2335

2436
/// A single column in a table schema.
25-
public struct Column: ColumnProtocol {
37+
public struct Column: ColumnProtocol, Encodable {
2638
public let name: String
2739
public let type: ColumnData
2840

Sources/PowerSync/Protocol/Schema/Index.swift

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ public protocol IndexProtocol: Sendable {
1212
var columns: [IndexedColumnProtocol] { get }
1313
}
1414

15-
public struct Index: IndexProtocol {
15+
public struct Index: IndexProtocol, Encodable {
1616
public let name: String
1717
public let columns: [IndexedColumnProtocol]
1818

@@ -47,4 +47,25 @@ public struct Index: IndexProtocol {
4747
) -> Index {
4848
return ascending(name: name, columns: [column])
4949
}
50+
51+
public func encode(to encoder: any Encoder) throws {
52+
enum CodingKeys: CodingKey {
53+
case name
54+
case columns
55+
}
56+
57+
var container = encoder.container(keyedBy: CodingKeys.self)
58+
try container.encode(name, forKey: .name)
59+
var columnsContainer = container.nestedUnkeyedContainer(forKey: .columns)
60+
for column in columns {
61+
enum IndexedColumnCodingKeys: CodingKey {
62+
case name
63+
case ascending
64+
}
65+
66+
var container = columnsContainer.nestedContainer(keyedBy: IndexedColumnCodingKeys.self)
67+
try container.encode(column.column, forKey: .name)
68+
try container.encode(column.ascending, forKey: .ascending)
69+
}
70+
}
5071
}

Sources/PowerSync/Protocol/Schema/RawTable.swift

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import Foundation
2+
13
/// A table that is managed by the user instead of being auto-created and migrated by the PowerSync SDK.
24
///
35
/// These tables give application developers full control over the table (including table and column constraints).
@@ -11,7 +13,7 @@
1113
///
1214
/// Note that raw tables are only supported when ``ConnectOptions/newClientImplementation``
1315
/// is enabled.
14-
public struct RawTable: BaseTableProtocol {
16+
public struct RawTable: BaseTableProtocol, Encodable {
1517
/// The name of the table as it appears in sync rules.
1618
///
1719
/// This doesn't necessarily have to match the statement that ``RawTable/put`` and ``RawTable/delete``
@@ -58,11 +60,52 @@ public struct RawTable: BaseTableProtocol {
5860
/// The output of this can be passed to the `powersync_create_raw_table_crud_trigger` SQL
5961
/// function to define triggers for this table.
6062
public func jsonDescription() -> String {
61-
return KotlinAdapter.Table.toKotlin(self).jsonDescription()
63+
let encoder = JSONEncoder()
64+
do {
65+
let serialized = try encoder.encode(self)
66+
return String(data: serialized, encoding: .utf8)!
67+
} catch {
68+
// An older version of the Swift SDK used to implement this method in Kotlin.
69+
// That could also throw an exception (which would crash the process), but that
70+
// was overlooked due to a missing @Throws annotation.
71+
// Now, we can't mark this as throws without breaking backwards compatibility.
72+
// We should convert this to be throwing in a future major release.
73+
fatalError("Serializing a raw table failed: \(error)")
74+
}
75+
}
76+
77+
internal func validate() throws(TableError) {
78+
if let schema {
79+
try schema.options.validate(tableName: name)
80+
}
81+
}
82+
83+
public func encode(to encoder: any Encoder) throws {
84+
enum CodingKeys: String, CodingKey {
85+
case name
86+
case put
87+
case delete
88+
case clear
89+
// For schema
90+
case tableName = "table_name"
91+
case syncedColumns = "synced_columns"
92+
}
93+
typealias Keys = TableOptionsCodingKeys<CodingKeys>
94+
95+
var container = encoder.container(keyedBy: Keys.self)
96+
try container.encode(name, forKey: .outer(.name))
97+
try container.encodeIfPresent(put, forKey: .outer(.put))
98+
try container.encodeIfPresent(delete, forKey: .outer(.delete))
99+
try container.encodeIfPresent(clear, forKey: .outer(.clear))
100+
if let schema {
101+
try container.encode(schema.tableName ?? name, forKey: .outer(.tableName))
102+
try container.encodeIfPresent(schema.syncedColumns, forKey: .outer(.syncedColumns))
103+
try schema.options.serializeTo(container)
104+
}
62105
}
63106
}
64107

65-
/// THe schema of a ``RawTable`` in the local database.
108+
/// The schema of a ``RawTable`` in the local database.
66109
///
67110
/// This information is optional when declaring raw tables. However, providing it allows the sync
68111
/// client to infer ``RawTable/put`` and ``RawTable/delete`` statements automatically.
@@ -95,7 +138,7 @@ public struct RawTableSchema: Sendable {
95138
}
96139

97140
/// A statement to run to sync server-side changes into a local raw table.
98-
public struct PendingStatement: Sendable {
141+
public struct PendingStatement: Sendable, Encodable {
99142
/// The SQL statement to execute.
100143
public let sql: String
101144
/// For parameters in the prepared statement, the values to fill in.
@@ -108,10 +151,21 @@ public struct PendingStatement: Sendable {
108151
self.sql = sql
109152
self.parameters = parameters
110153
}
154+
155+
public func encode(to encoder: any Encoder) throws {
156+
enum CodingKeys: String, CodingKey {
157+
case sql
158+
case parameters = "params"
159+
}
160+
161+
var container = encoder.container(keyedBy: CodingKeys.self)
162+
try container.encode(self.sql, forKey: .sql)
163+
try container.encode(self.parameters, forKey: .parameters)
164+
}
111165
}
112166

113167
/// A parameter that can be used in a ``PendingStatement``.
114-
public enum PendingStatementParameter: Sendable {
168+
public enum PendingStatementParameter: Sendable, Encodable {
115169
/// A value that resolves to the textual id of the row to insert, update or delete.
116170
case id
117171
/// A value that resolves to the value of a column in a `PUT` operation for inserts or updates.
@@ -122,4 +176,22 @@ public enum PendingStatementParameter: Sendable {
122176
/// Resolves to a JSON object containing all columns from the synced row that haven't been matched
123177
/// by a ``PendingStatementParameter/column`` value in the same statement.
124178
case rest
179+
180+
public func encode(to encoder: any Encoder) throws {
181+
switch self {
182+
case .id:
183+
var container = encoder.singleValueContainer()
184+
try container.encode("Id")
185+
case .column(let name):
186+
enum CodingKeys: String, CodingKey {
187+
case column = "Column"
188+
}
189+
190+
var container = encoder.container(keyedBy: CodingKeys.self)
191+
try container.encode(name, forKey: .column)
192+
case .rest:
193+
var container = encoder.singleValueContainer()
194+
try container.encode("Rest")
195+
}
196+
}
125197
}

Sources/PowerSync/Protocol/Schema/Schema.swift

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ public protocol SchemaProtocol: Sendable {
1212
func validate() throws
1313
}
1414

15-
public struct Schema: SchemaProtocol {
15+
public struct Schema: SchemaProtocol, Encodable {
1616
public let tables: [Table]
1717
public let rawTables: [RawTable]
1818

@@ -50,6 +50,32 @@ public struct Schema: SchemaProtocol {
5050
}
5151
try table.validate()
5252
}
53+
54+
for table in rawTables {
55+
// Only check for duplicate names if the raw table has a fixed local schema
56+
// name. By default, the name in raw tables refers to the name of the table as
57+
// defined in Sync Streams. The local table populated by put/delete statements
58+
// might be different and we can't check that.
59+
if let schema = table.schema {
60+
let name = schema.tableName ?? table.name
61+
if !tableNames.insert(name).inserted {
62+
throw SchemaError.duplicateTableName(name)
63+
}
64+
}
65+
66+
try table.validate()
67+
}
68+
}
69+
70+
public func encode(to encoder: any Encoder) throws {
71+
enum CodingKeys: String, CodingKey {
72+
case tables
73+
case rawTables = "raw_tables"
74+
}
75+
76+
var container = encoder.container(keyedBy: CodingKeys.self)
77+
try container.encode(self.tables, forKey: .tables)
78+
try container.encode(self.rawTables, forKey: .rawTables)
5379
}
5480
}
5581

Sources/PowerSync/Protocol/Schema/Table.swift

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ private let MAX_AMOUNT_OF_COLUMNS = 63
3030
///
3131
/// A single table in the schema.
3232
///
33-
public struct Table: TableProtocol {
33+
public struct Table: TableProtocol, Encodable {
3434
public let name: String
3535
public let columns: [Column]
3636
public let indexes: [Index]
@@ -119,15 +119,7 @@ public struct Table: TableProtocol {
119119
throw TableError.invalidViewName(viewName: viewNameOverride)
120120
}
121121

122-
if localOnly {
123-
if trackPreviousValues != nil {
124-
throw TableError.trackPreviousForLocalTable(tableName: name)
125-
}
126-
if trackMetadata {
127-
throw TableError.metadataForLocalTable(tableName: name)
128-
}
129-
}
130-
122+
try options.validate(tableName: name)
131123
var columnNames = Set<String>(["id"])
132124

133125
for column in columns {
@@ -184,6 +176,23 @@ public struct Table: TableProtocol {
184176
indexNames.insert(index.name)
185177
}
186178
}
179+
180+
public func encode(to encoder: any Encoder) throws {
181+
enum CodingKeys: String, CodingKey {
182+
case name
183+
case viewName = "view_name"
184+
case columns
185+
case indexes
186+
}
187+
typealias Keys = TableOptionsCodingKeys<CodingKeys>
188+
189+
var container = encoder.container(keyedBy: Keys.self)
190+
try container.encode(name, forKey: .outer(.name))
191+
try container.encodeIfPresent(viewNameOverride, forKey: .outer(.viewName))
192+
try container.encode(columns, forKey: .outer(.columns))
193+
try container.encode(indexes, forKey: .outer(.indexes))
194+
try options.serializeTo(container)
195+
}
187196
}
188197

189198
public enum TableError: Error {

Sources/PowerSync/Protocol/Schema/TableOptions.swift

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,26 @@ public struct TableOptions: TableOptionsProtocol {
7171
self.trackPreviousValues = trackPreviousValues
7272
self.ignoreEmptyUpdates = ignoreEmptyUpdates
7373
}
74+
75+
internal func validate(tableName: String) throws(TableError) {
76+
if localOnly {
77+
if trackPreviousValues != nil {
78+
throw TableError.trackPreviousForLocalTable(tableName: tableName)
79+
}
80+
if trackMetadata {
81+
throw TableError.metadataForLocalTable(tableName: tableName)
82+
}
83+
}
84+
}
85+
86+
internal func serializeTo<T: CodingKey>(_ container: KeyedEncodingContainer<TableOptionsCodingKeys<T>>) throws {
87+
var container = container
88+
try container.encode(localOnly, forKey: .localOnly)
89+
try container.encode(insertOnly, forKey: .insertOnly)
90+
try container.encode(trackMetadata, forKey: .includeMetadata)
91+
try container.encode(ignoreEmptyUpdates, forKey: .ignoreEmptyUpdate)
92+
try trackPreviousValues?.serializeTo(container)
93+
}
7494
}
7595

7696
/// Options to include old values in ``CrudEntry/previousValues`` for update statements.
@@ -91,4 +111,57 @@ public struct TrackPreviousValuesOptions: Sendable {
91111
self.columnFilter = columnFilter
92112
self.onlyWhenChanged = onlyWhenChanged
93113
}
114+
115+
internal func serializeTo<T: CodingKey>(_ container: KeyedEncodingContainer<TableOptionsCodingKeys<T>>) throws {
116+
var container = container
117+
if let columnFilter {
118+
try container.encode(columnFilter, forKey: .diffIncludeOld)
119+
} else {
120+
try container.encode(true, forKey: .diffIncludeOld)
121+
}
122+
try container.encode(onlyWhenChanged, forKey: .includeOldOnlyWhenChanged)
123+
}
124+
}
125+
126+
/// Coding keys for table options (which are always embedded into another outer object.
127+
internal enum TableOptionsCodingKeys<T: CodingKey>: CodingKey {
128+
case outer(T)
129+
case diffIncludeOld
130+
case localOnly
131+
case insertOnly
132+
case includeMetadata
133+
case includeOldOnlyWhenChanged
134+
case ignoreEmptyUpdate
135+
136+
// We don't use these for decoding, so we can return nil here.
137+
init?(stringValue: String) {
138+
return nil
139+
}
140+
init?(intValue: Int) {
141+
return nil
142+
}
143+
144+
var stringValue: String {
145+
switch self {
146+
case .outer(let field):
147+
return field.stringValue
148+
case .diffIncludeOld:
149+
return "include_old"
150+
case .localOnly:
151+
return "local_only"
152+
case .insertOnly:
153+
return "insert_only"
154+
case .includeMetadata:
155+
return "include_metadata"
156+
case .includeOldOnlyWhenChanged:
157+
return "include_old_only_when_changed"
158+
case .ignoreEmptyUpdate:
159+
return "ignore_empty_update"
160+
}
161+
}
162+
163+
// We'll only encode into string-keyed dictionaries (JSON objects).
164+
var intValue: Int? {
165+
nil
166+
}
94167
}

0 commit comments

Comments
 (0)