Skip to content

Commit 37a53bf

Browse files
authored
Prepare Kotlin update, add schema options (#46)
* Add schema options * Add changelog entry * Update docs on local builds * Fix syntax error
1 parent b17b4d4 commit 37a53bf

File tree

10 files changed

+272
-26
lines changed

10 files changed

+272
-26
lines changed

CHANGELOG.md

+5-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22

33
## unreleased
44

5-
- Add sync progress information through `SyncStatusData.downloadProgress`.
5+
* Add sync progress information through `SyncStatusData.downloadProgress`.
6+
* Add `trackPreviousValues` option on `Table` which sets `CrudEntry.previousValues` to previous values on updates.
7+
* Add `trackMetadata` option on `Table` which adds a `_metadata` column that can be used for updates.
8+
The configured metadata is available through `CrudEntry.metadata`.
9+
* Add `ignoreEmptyUpdates` option which skips creating CRUD entries for updates that don't change any values.
610

711
# 1.0.0
812

Package.resolved

-9
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,5 @@
11
{
22
"pins" : [
3-
{
4-
"identity" : "powersync-kotlin",
5-
"kind" : "remoteSourceControl",
6-
"location" : "https://github.com/powersync-ja/powersync-kotlin.git",
7-
"state" : {
8-
"revision" : "ccd2e595195c59d570eb93a878ad6a5cfca72ada",
9-
"version" : "1.0.1+SWIFT.0"
10-
}
11-
},
123
{
134
"identity" : "powersync-sqlite-core-swift",
145
"kind" : "remoteSourceControl",

Package.swift

+32-4
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,35 @@
44
import PackageDescription
55
let packageName = "PowerSync"
66

7+
// Set this to the absolute path of your Kotlin SDK checkout if you want to use a local Kotlin
8+
// build. Also see docs/LocalBuild.md for details
9+
let localKotlinSdkOverride: String? = nil
10+
11+
// Our target and dependency setup is different when a local Kotlin SDK is used. Without the local
12+
// SDK, we have no package dependency on Kotlin and download the XCFramework from Kotlin releases as
13+
// a binary target.
14+
// With a local SDK, we point to a `Package.swift` within the Kotlin SDK containing a target pointing
15+
// towards a local framework build
16+
var conditionalDependencies: [Package.Dependency] = []
17+
var conditionalTargets: [Target] = []
18+
var kotlinTargetDependency = Target.Dependency.target(name: "PowerSyncKotlin")
19+
20+
if let kotlinSdkPath = localKotlinSdkOverride {
21+
// We can't depend on local XCFrameworks outside of this project's root, so there's a Package.swift
22+
// in the PowerSyncKotlin project pointing towards a local build.
23+
conditionalDependencies.append(.package(path: "\(kotlinSdkPath)/PowerSyncKotlin"))
24+
25+
kotlinTargetDependency = .product(name: "PowerSyncKotlin", package: "PowerSyncKotlin")
26+
} else {
27+
// Not using a local build, so download from releases
28+
conditionalTargets.append(.binaryTarget(
29+
name: "PowerSyncKotlin",
30+
// TODO: Use GitHub release once https://github.com/powersync-ja/powersync-kotlin/releases/tag/untagged-fde4386dec502ec27067 is published
31+
url: "https://fsn1.your-objectstorage.com/simon-public/powersync.zip",
32+
checksum: "b6770dc22ae31315adc599e653fea99614226312fe861dbd8764e922a5a83b09"
33+
))
34+
}
35+
736
let package = Package(
837
name: packageName,
938
platforms: [
@@ -17,21 +46,20 @@ let package = Package(
1746
targets: ["PowerSync"]),
1847
],
1948
dependencies: [
20-
.package(url: "https://github.com/powersync-ja/powersync-kotlin.git", "1.0.1+SWIFT.0"..<"1.1.0+SWIFT.0"),
2149
.package(url: "https://github.com/powersync-ja/powersync-sqlite-core-swift.git", "0.3.14"..<"0.4.0")
22-
],
50+
] + conditionalDependencies,
2351
targets: [
2452
// Targets are the basic building blocks of a package, defining a module or a test suite.
2553
// Targets can depend on other targets in this package and products from dependencies.
2654
.target(
2755
name: packageName,
2856
dependencies: [
29-
.product(name: "PowerSyncKotlin", package: "powersync-kotlin"),
57+
kotlinTargetDependency,
3058
.product(name: "PowerSyncSQLiteCore", package: "powersync-sqlite-core-swift")
3159
]),
3260
.testTarget(
3361
name: "PowerSyncTests",
3462
dependencies: ["PowerSync"]
3563
),
36-
]
64+
] + conditionalTargets
3765
)

Sources/PowerSync/Kotlin/KotlinAdapter.swift

+14-2
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,25 @@ enum KotlinAdapter {
2323

2424
struct Table {
2525
static func toKotlin(_ table: TableProtocol) -> PowerSyncKotlin.Table {
26-
PowerSyncKotlin.Table(
26+
let trackPreviousKotlin: PowerSyncKotlin.TrackPreviousValuesOptions? = if let track = table.trackPreviousValues {
27+
PowerSyncKotlin.TrackPreviousValuesOptions(
28+
columnFilter: track.columnFilter,
29+
onlyWhenChanged: track.onlyWhenChanged
30+
)
31+
} else {
32+
nil
33+
}
34+
35+
return PowerSyncKotlin.Table(
2736
name: table.name,
2837
columns: table.columns.map { Column.toKotlin($0) },
2938
indexes: table.indexes.map { Index.toKotlin($0) },
3039
localOnly: table.localOnly,
3140
insertOnly: table.insertOnly,
32-
viewNameOverride: table.viewNameOverride
41+
viewNameOverride: table.viewNameOverride,
42+
trackMetadata: table.trackMetadata,
43+
trackPreviousValues: trackPreviousKotlin,
44+
ignoreEmptyUpdates: table.ignoreEmptyUpdates
3345
)
3446
}
3547
}

Sources/PowerSync/Kotlin/db/KotlinCrudEntry.swift

+8
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,17 @@ struct KotlinCrudEntry : CrudEntry {
2828
entry.transactionId?.int64Value
2929
}
3030

31+
var metadata: String? {
32+
entry.metadata
33+
}
34+
3135
var opData: [String : String?]? {
3236
/// Kotlin represents this as Map<String, String?>, but this is
3337
/// converted to [String: Any] by SKIEE
3438
entry.opData?.mapValues { $0 as? String }
3539
}
40+
41+
var previousValues: [String : String?]? {
42+
entry.previousValues?.mapValues { $0 as? String }
43+
}
3644
}

Sources/PowerSync/Protocol/Schema/Table.swift

+64-2
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,46 @@ public protocol TableProtocol {
2626
///
2727
var viewNameOverride: String? { get }
2828
var viewName: String { get }
29+
30+
/// Whether to add a hidden `_metadata` column that will ne abled for updates to
31+
/// attach custom information about writes.
32+
///
33+
/// When the `_metadata` column is written to for inserts or updates, its value will not be
34+
/// part of ``CrudEntry/opData``. Instead, it is reported as ``CrudEntry/metadata``,
35+
/// allowing ``PowerSyncBackendConnector``s to handle these updates specially.
36+
var trackMetadata: Bool { get }
37+
38+
/// When set to a non-`nil` value, track old values of columns for ``CrudEntry/previousValues``.
39+
///
40+
/// See ``TrackPreviousValuesOptions`` for details
41+
var trackPreviousValues: TrackPreviousValuesOptions? { get }
42+
43+
/// Whether an `UPDATE` statement that doesn't change any values should be ignored entirely when
44+
/// creating CRUD entries.
45+
///
46+
/// This is disabled by default, meaning that an `UPDATE` on a row that doesn't change values would
47+
/// create a ``CrudEntry`` with an empty ``CrudEntry/opData`` and ``UpdateType/patch``.
48+
var ignoreEmptyUpdates: Bool { get }
49+
}
50+
51+
/// Options to include old values in ``CrudEntry/previousValues`` for update statements.
52+
///
53+
/// These options are enabled by passing them to a non-local ``Table`` constructor.
54+
public struct TrackPreviousValuesOptions {
55+
/// A filter of column names for which updates should be tracked.
56+
///
57+
/// When set to a non-`nil` value, columns not included in this list will not appear in
58+
/// ``CrudEntry/previousValues``. By default, all columns are included.
59+
public let columnFilter: [String]?;
60+
61+
/// Whether to only include old values when they were changed by an update, instead of always including
62+
/// all old values.
63+
public let onlyWhenChanged: Bool;
64+
65+
public init(columnFilter: [String]? = nil, onlyWhenChanged: Bool = false) {
66+
self.columnFilter = columnFilter
67+
self.onlyWhenChanged = onlyWhenChanged
68+
}
2969
}
3070

3171
private let MAX_AMOUNT_OF_COLUMNS = 63
@@ -40,6 +80,9 @@ public struct Table: TableProtocol {
4080
public let localOnly: Bool
4181
public let insertOnly: Bool
4282
public let viewNameOverride: String?
83+
public let trackMetadata: Bool
84+
public let trackPreviousValues: TrackPreviousValuesOptions?
85+
public let ignoreEmptyUpdates: Bool
4386

4487
public var viewName: String {
4588
viewNameOverride ?? name
@@ -60,14 +103,20 @@ public struct Table: TableProtocol {
60103
indexes: [Index] = [],
61104
localOnly: Bool = false,
62105
insertOnly: Bool = false,
63-
viewNameOverride: String? = nil
106+
viewNameOverride: String? = nil,
107+
trackMetadata: Bool = false,
108+
trackPreviousValues: TrackPreviousValuesOptions? = nil,
109+
ignoreEmptyUpdates: Bool = false
64110
) {
65111
self.name = name
66112
self.columns = columns
67113
self.indexes = indexes
68114
self.localOnly = localOnly
69115
self.insertOnly = insertOnly
70116
self.viewNameOverride = viewNameOverride
117+
self.trackMetadata = trackMetadata
118+
self.trackPreviousValues = trackPreviousValues
119+
self.ignoreEmptyUpdates = ignoreEmptyUpdates
71120
}
72121

73122
private func hasInvalidSqliteCharacters(_ string: String) -> Bool {
@@ -82,11 +131,20 @@ public struct Table: TableProtocol {
82131
if columns.count > MAX_AMOUNT_OF_COLUMNS {
83132
throw TableError.tooManyColumns(tableName: name, count: columns.count)
84133
}
85-
134+
86135
if let viewNameOverride = viewNameOverride,
87136
hasInvalidSqliteCharacters(viewNameOverride) {
88137
throw TableError.invalidViewName(viewName: viewNameOverride)
89138
}
139+
140+
if localOnly {
141+
if trackPreviousValues != nil {
142+
throw TableError.trackPreviousForLocalTable(tableName: name)
143+
}
144+
if trackMetadata {
145+
throw TableError.metadataForLocalTable(tableName: name)
146+
}
147+
}
90148

91149
var columnNames = Set<String>(["id"])
92150

@@ -156,4 +214,8 @@ public enum TableError: Error {
156214
case duplicateIndex(tableName: String, indexName: String)
157215
case invalidIndexName(tableName: String, indexName: String)
158216
case columnNotFound(tableName: String, columnName: String, indexName: String)
217+
/// Local-only tables can't enable ``Table/trackMetadata`` because no updates are tracked for those tables at all.
218+
case metadataForLocalTable(tableName: String)
219+
/// Local-only tables can't enable ``Table/trackPreviousValues`` because no updates are tracked for those tables at all.
220+
case trackPreviousForLocalTable(tableName: String)
159221
}

Sources/PowerSync/Protocol/db/CrudEntry.swift

+14
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,20 @@ public protocol CrudEntry {
4444
/// The transaction ID associated with the entry, if any.
4545
var transactionId: Int64? { get }
4646

47+
/// User-defined metadata that can be attached to writes.
48+
///
49+
/// This is the value the `_metadata` column had when the write to the database was made,
50+
/// allowing backend connectors to e.g. identify a write and tear it specially.
51+
///
52+
/// Note that the `_metadata` column and this field are only available when ``Table/trackMetadata``
53+
/// is enabled.
54+
var metadata: String? { get }
55+
4756
/// The operation data associated with the entry, represented as a dictionary of column names to their values.
4857
var opData: [String: String?]? { get }
58+
59+
/// Previous values before this change.
60+
///
61+
/// These values can be tracked for `UPDATE` statements when ``Table/trackPreviousValues`` is enabled.
62+
var previousValues: [String: String?]? { get }
4963
}

Tests/PowerSyncTests/CrudTests.swift

+89
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,95 @@ final class CrudTests: XCTestCase {
3333
database = nil
3434
try await super.tearDown()
3535
}
36+
37+
func testTrackMetadata() async throws {
38+
try await database.updateSchema(schema: Schema(tables: [
39+
Table(name: "lists", columns: [.text("name")], trackMetadata: true)
40+
]))
41+
42+
try await database.execute("INSERT INTO lists (id, name, _metadata) VALUES (uuid(), 'test', 'so meta')")
43+
guard let batch = try await database.getNextCrudTransaction() else {
44+
return XCTFail("Should have batch after insert")
45+
}
46+
47+
XCTAssertEqual(batch.crud[0].metadata, "so meta")
48+
}
49+
50+
func testTrackPreviousValues() async throws {
51+
try await database.updateSchema(schema: Schema(tables: [
52+
Table(
53+
name: "lists",
54+
columns: [.text("name"), .text("content")],
55+
trackPreviousValues: TrackPreviousValuesOptions()
56+
)
57+
]))
58+
59+
try await database.execute("INSERT INTO lists (id, name, content) VALUES (uuid(), 'entry', 'content')")
60+
try await database.execute("DELETE FROM ps_crud")
61+
try await database.execute("UPDATE lists SET name = 'new name'")
62+
63+
guard let batch = try await database.getNextCrudTransaction() else {
64+
return XCTFail("Should have batch after update")
65+
}
66+
67+
XCTAssertEqual(batch.crud[0].previousValues, ["name": "entry", "content": "content"])
68+
}
69+
70+
func testTrackPreviousValuesWithFilter() async throws {
71+
try await database.updateSchema(schema: Schema(tables: [
72+
Table(
73+
name: "lists",
74+
columns: [.text("name"), .text("content")],
75+
trackPreviousValues: TrackPreviousValuesOptions(
76+
columnFilter: ["name"]
77+
)
78+
)
79+
]))
80+
81+
try await database.execute("INSERT INTO lists (id, name, content) VALUES (uuid(), 'entry', 'content')")
82+
try await database.execute("DELETE FROM ps_crud")
83+
try await database.execute("UPDATE lists SET name = 'new name'")
84+
85+
guard let batch = try await database.getNextCrudTransaction() else {
86+
return XCTFail("Should have batch after update")
87+
}
88+
89+
XCTAssertEqual(batch.crud[0].previousValues, ["name": "entry"])
90+
}
91+
92+
func testTrackPreviousValuesOnlyWhenChanged() async throws {
93+
try await database.updateSchema(schema: Schema(tables: [
94+
Table(
95+
name: "lists",
96+
columns: [.text("name"), .text("content")],
97+
trackPreviousValues: TrackPreviousValuesOptions(
98+
onlyWhenChanged: true
99+
)
100+
)
101+
]))
102+
103+
try await database.execute("INSERT INTO lists (id, name, content) VALUES (uuid(), 'entry', 'content')")
104+
try await database.execute("DELETE FROM ps_crud")
105+
try await database.execute("UPDATE lists SET name = 'new name'")
106+
107+
guard let batch = try await database.getNextCrudTransaction() else {
108+
return XCTFail("Should have batch after update")
109+
}
110+
111+
XCTAssertEqual(batch.crud[0].previousValues, ["name": "entry"])
112+
}
113+
114+
func testIgnoreEmptyUpdate() async throws {
115+
try await database.updateSchema(schema: Schema(tables: [
116+
Table(name: "lists", columns: [.text("name")], ignoreEmptyUpdates: true)
117+
]))
118+
try await database.execute("INSERT INTO lists (id, name) VALUES (uuid(), 'test')")
119+
try await database.execute("DELETE FROM ps_crud")
120+
try await database.execute("UPDATE lists SET name = 'test'") // Same value!
121+
122+
let batch = try await database.getNextCrudTransaction()
123+
XCTAssertNil(batch)
124+
}
36125

37126
func testCrudBatch() async throws {
38127
// Create some items

Tests/PowerSyncTests/Schema/TableTests.swift

+29
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,35 @@ final class TableTests: XCTestCase {
190190
}
191191
}
192192

193+
func testInvalidLocalOnlyTrackMetadata() {
194+
let table = Table(name: "test", columns: [Column.text("name")], localOnly: true, trackMetadata: true)
195+
196+
XCTAssertThrowsError(try table.validate()) { error in
197+
guard case TableError.metadataForLocalTable(let tableName) = error else {
198+
XCTFail("Expected metadataForLocalTable error")
199+
return
200+
}
201+
XCTAssertEqual(tableName, "test")
202+
}
203+
}
204+
205+
func testInvalidLocalOnlyTrackPrevious() {
206+
let table = Table(
207+
name: "test_prev",
208+
columns: [Column.text("name")],
209+
localOnly: true,
210+
trackPreviousValues: TrackPreviousValuesOptions()
211+
)
212+
213+
XCTAssertThrowsError(try table.validate()) { error in
214+
guard case TableError.trackPreviousForLocalTable(let tableName) = error else {
215+
XCTFail("Expected trackPreviousForLocalTable error")
216+
return
217+
}
218+
XCTAssertEqual(tableName, "test_prev")
219+
}
220+
}
221+
193222
func testValidTableValidation() throws {
194223
let table = Table(
195224
name: "users",

0 commit comments

Comments
 (0)