From 435344ab993ac489b212eb55f08d3b07292b02a2 Mon Sep 17 00:00:00 2001 From: DominicGBauer Date: Wed, 5 Feb 2025 14:59:43 +0200 Subject: [PATCH 1/4] feat: add SqlCursor function improvements --- .../PowerSync/SystemManager.swift | 26 ++--- .../Kotlin/KotlinPowerSyncDatabaseImpl.swift | 65 ++++++++++++- Sources/PowerSync/Kotlin/KotlinTypes.swift | 9 +- Sources/PowerSync/Kotlin/SqlCursor.swift | 68 +++++++++++++ Sources/PowerSync/QueriesProtocol.swift | 31 ++++++ .../KotlinPowerSyncDatabaseImplTests.swift | 11 ++- .../Kotlin/SqlCursorTests.swift | 96 +++++++++++++++++++ 7 files changed, 283 insertions(+), 23 deletions(-) create mode 100644 Sources/PowerSync/Kotlin/SqlCursor.swift create mode 100644 Tests/PowerSyncTests/Kotlin/SqlCursorTests.swift diff --git a/Demo/PowerSyncExample/PowerSync/SystemManager.swift b/Demo/PowerSyncExample/PowerSync/SystemManager.swift index af1d38f..3287f36 100644 --- a/Demo/PowerSyncExample/PowerSync/SystemManager.swift +++ b/Demo/PowerSyncExample/PowerSync/SystemManager.swift @@ -39,10 +39,10 @@ class SystemManager { parameters: [], mapper: { cursor in ListContent( - id: cursor.getString(index: 0)!, - name: cursor.getString(index: 1)!, - createdAt: cursor.getString(index: 2)!, - ownerId: cursor.getString(index: 3)! + id: try cursor.getString(name: "id"), + name: try cursor.getString(name: "name"), + createdAt: try cursor.getString(name: "created_at"), + ownerId: try cursor.getString(name: "owner_id") ) } ) { @@ -77,15 +77,15 @@ class SystemManager { parameters: [listId], mapper: { cursor in return Todo( - id: cursor.getString(index: 0)!, - listId: cursor.getString(index: 1)!, - photoId: cursor.getString(index: 2), - description: cursor.getString(index: 3)!, - isComplete: cursor.getBoolean(index: 4)! as! Bool, - createdAt: cursor.getString(index: 5), - completedAt: cursor.getString(index: 6), - createdBy: cursor.getString(index: 7), - completedBy: cursor.getString(index: 8) + id: try cursor.getString(name: "id"), + listId: try cursor.getString(name: "list_id"), + photoId: try cursor.getStringOptional(name: "photo_id"), + description: try cursor.getString(name: "description"), + isComplete: try cursor.getBoolean(name: "completed"), + createdAt: try cursor.getString(name: "created_at"), + completedAt: try cursor.getStringOptional(name: "completed_at"), + createdBy: try cursor.getStringOptional(name: "created_by"), + completedBy: try cursor.getStringOptional(name: "completed_by") ) } ) { diff --git a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift index ccf0244..ad13987 100644 --- a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift +++ b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift @@ -79,7 +79,21 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { mapper: mapper ) as! RowType } - + + func get( + sql: String, + parameters: [Any]?, + mapper: @escaping (SqlCursor) throws -> RowType + ) async throws -> RowType { + try await kotlinDatabase.get( + sql: sql, + parameters: parameters, + mapper: { cursor in + try! mapper(cursor) + } + ) as! RowType + } + func getAll( sql: String, parameters: [Any]?, @@ -91,6 +105,20 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { mapper: mapper ) as! [RowType] } + + func getAll( + sql: String, + parameters: [Any]?, + mapper: @escaping (SqlCursor) throws -> RowType + ) async throws -> [RowType] { + try await kotlinDatabase.getAll( + sql: sql, + parameters: parameters, + mapper: { cursor in + try! mapper(cursor) + } + ) as! [RowType] + } func getOptional( sql: String, @@ -103,6 +131,20 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { mapper: mapper ) as! RowType? } + + func getOptional( + sql: String, + parameters: [Any]?, + mapper: @escaping (SqlCursor) throws -> RowType + ) async throws -> RowType? { + try await kotlinDatabase.getOptional( + sql: sql, + parameters: parameters, + mapper: { cursor in + try! mapper(cursor) + } + ) as! RowType? + } func watch( sql: String, @@ -123,6 +165,27 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { } } + func watch( + sql: String, + parameters: [Any]?, + mapper: @escaping (SqlCursor) throws -> RowType + ) -> AsyncStream<[RowType]> { + AsyncStream { continuation in + Task { + for await values in self.kotlinDatabase.watch( + sql: sql, + parameters: parameters, + mapper: { cursor in + try! mapper(cursor) + } + ) { + continuation.yield(values as! [RowType]) + } + continuation.finish() + } + } + } + public func writeTransaction(callback: @escaping (any PowerSyncTransaction) -> R) async throws -> R { return try await kotlinDatabase.writeTransaction(callback: callback) as! R } diff --git a/Sources/PowerSync/Kotlin/KotlinTypes.swift b/Sources/PowerSync/Kotlin/KotlinTypes.swift index a756371..ffb47a9 100644 --- a/Sources/PowerSync/Kotlin/KotlinTypes.swift +++ b/Sources/PowerSync/Kotlin/KotlinTypes.swift @@ -1,12 +1,11 @@ import PowerSyncKotlin -public typealias KotlinPowerSyncBackendConnector = PowerSyncKotlin.PowerSyncBackendConnector +typealias KotlinPowerSyncBackendConnector = PowerSyncKotlin.PowerSyncBackendConnector public typealias CrudEntry = PowerSyncKotlin.CrudEntry public typealias CrudBatch = PowerSyncKotlin.CrudBatch public typealias SyncStatus = PowerSyncKotlin.SyncStatus -public typealias SqlCursor = PowerSyncKotlin.RuntimeSqlCursor +public typealias SqlCursor = PowerSyncKotlin.SqlCursor public typealias JsonParam = PowerSyncKotlin.JsonParam public typealias CrudTransaction = PowerSyncKotlin.CrudTransaction -public typealias KotlinPowerSyncCredentials = PowerSyncKotlin.PowerSyncCredentials -public typealias KotlinPowerSyncDatabase = PowerSyncKotlin.PowerSyncDatabase - +typealias KotlinPowerSyncCredentials = PowerSyncKotlin.PowerSyncCredentials +typealias KotlinPowerSyncDatabase = PowerSyncKotlin.PowerSyncDatabase diff --git a/Sources/PowerSync/Kotlin/SqlCursor.swift b/Sources/PowerSync/Kotlin/SqlCursor.swift new file mode 100644 index 0000000..5ed1f1f --- /dev/null +++ b/Sources/PowerSync/Kotlin/SqlCursor.swift @@ -0,0 +1,68 @@ +import Foundation +import PowerSyncKotlin + +extension SqlCursor { + private func getColumnIndex(name: String) throws -> Int32 { + guard let columnIndex = columnNames[name]?.int32Value else { + throw SqlCursorError.columnNotFound(name) + } + return columnIndex + } + + private func getValue(name: String, getter: (Int32) -> T?) throws -> T { + let columnIndex = try getColumnIndex(name: name) + guard let value = getter(columnIndex) else { + throw SqlCursorError.nullValueFound(name) + } + return value + } + + private func getOptionalValue(name: String, getter: (String) -> T?) throws -> T? { + _ = try getColumnIndex(name: name) + return getter(name) + } + + public func getBoolean(name: String) throws -> Bool { + try getValue(name: name) { getBoolean(index: $0)?.boolValue } + } + + public func getDouble(name: String) throws -> Double { + try getValue(name: name) { getDouble(index: $0)?.doubleValue } + } + + public func getLong(name: String) throws -> Int { + try getValue(name: name) { getLong(index: $0)?.intValue } + } + + public func getString(name: String) throws -> String { + try getValue(name: name) { getString(index: $0) } + } + + public func getBooleanOptional(name: String) throws -> Bool? { + try getOptionalValue(name: name) { getBooleanOptional(name: $0)?.boolValue } + } + + public func getDoubleOptional(name: String) throws -> Double? { + try getOptionalValue(name: name) { getDoubleOptional(name: $0)?.doubleValue } + } + + public func getLongOptional(name: String) throws -> Int? { + try getOptionalValue(name: name) { getLongOptional(name: $0)?.intValue } + } + + public func getStringOptional(name: String) throws -> String? { + try getOptionalValue(name: name) { PowerSyncKotlin.SqlCursorKt.getStringOptional(self, name: $0) } + } +} + +enum SqlCursorError: Error { + case nullValue(message: String) + + static func columnNotFound(_ name: String) -> SqlCursorError { + .nullValue(message: "Column '\(name)' not found") + } + + static func nullValueFound(_ name: String) -> SqlCursorError { + .nullValue(message: "Null value found for column \(name)") + } +} diff --git a/Sources/PowerSync/QueriesProtocol.swift b/Sources/PowerSync/QueriesProtocol.swift index 5ea28bb..4374e0b 100644 --- a/Sources/PowerSync/QueriesProtocol.swift +++ b/Sources/PowerSync/QueriesProtocol.swift @@ -16,6 +16,15 @@ public protocol Queries { mapper: @escaping (SqlCursor) -> RowType ) async throws -> RowType + /// Execute a read-only (SELECT) query and return a single result. + /// If there is no result, throws an IllegalArgumentException. + /// See `getOptional` for queries where the result might be empty. + func get( + sql: String, + parameters: [Any]?, + mapper: @escaping (SqlCursor) throws -> RowType + ) async throws -> RowType + /// Execute a read-only (SELECT) query and return the results. func getAll( sql: String, @@ -23,6 +32,13 @@ public protocol Queries { mapper: @escaping (SqlCursor) -> RowType ) async throws -> [RowType] + /// Execute a read-only (SELECT) query and return the results. + func getAll( + sql: String, + parameters: [Any]?, + mapper: @escaping (SqlCursor) throws -> RowType + ) async throws -> [RowType] + /// Execute a read-only (SELECT) query and return a single optional result. func getOptional( sql: String, @@ -30,6 +46,13 @@ public protocol Queries { mapper: @escaping (SqlCursor) -> RowType ) async throws -> RowType? + /// Execute a read-only (SELECT) query and return a single optional result. + func getOptional( + sql: String, + parameters: [Any]?, + mapper: @escaping (SqlCursor) throws -> RowType + ) async throws -> RowType? + /// Execute a read-only (SELECT) query every time the source tables are modified /// and return the results as an array in a Publisher. func watch( @@ -38,6 +61,14 @@ public protocol Queries { mapper: @escaping (SqlCursor) -> RowType ) -> AsyncStream<[RowType]> + /// Execute a read-only (SELECT) query every time the source tables are modified + /// and return the results as an array in a Publisher. + func watch( + sql: String, + parameters: [Any]?, + mapper: @escaping (SqlCursor) throws -> RowType + ) -> AsyncStream<[RowType]> + /// Execute a write transaction with the given callback func writeTransaction(callback: @escaping (any PowerSyncTransaction) -> R) async throws -> R diff --git a/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift b/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift index 1885f64..9490217 100644 --- a/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift +++ b/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift @@ -38,9 +38,9 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { parameters: ["1"] ) { cursor in ( - cursor.getString(index: 0)!, - cursor.getString(index: 1)!, - cursor.getString(index: 2)! + try cursor.getString(name: "id"), + try cursor.getString(name: "name"), + try cursor.getString(name: "email") ) } @@ -84,7 +84,10 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { sql: "SELECT id, name FROM users ORDER BY id", parameters: nil ) { cursor in - (cursor.getString(index: 0)!, cursor.getString(index: 1)!) + ( + try cursor.getString(name: "id"), + try cursor.getString(name: "name") + ) } XCTAssertEqual(users.count, 2) diff --git a/Tests/PowerSyncTests/Kotlin/SqlCursorTests.swift b/Tests/PowerSyncTests/Kotlin/SqlCursorTests.swift new file mode 100644 index 0000000..b8ae92b --- /dev/null +++ b/Tests/PowerSyncTests/Kotlin/SqlCursorTests.swift @@ -0,0 +1,96 @@ +import XCTest +@testable import PowerSync + +struct User { + let id: String + let count: Int + let isActive: Bool + let weight: Double +} + +struct UserOptional { + let id: String + let count: Int? + let isActive: Bool? + let weight: Double? + let description: String? +} + +final class SqlCursorTests: XCTestCase { + private var database: KotlinPowerSyncDatabaseImpl! + private var schema: Schema! + + override func setUp() async throws { + try await super.setUp() + schema = Schema(tables: [ + Table(name: "users", columns: [ + .text("count"), + .integer("is_active"), + .real("weight"), + .text("description") + ]) + ]) + + database = KotlinPowerSyncDatabaseImpl( + schema: schema, + dbFilename: ":memory:" + ) + try await database.disconnectAndClear() + } + + override func tearDown() async throws { + try await database.disconnectAndClear() + database = nil + try await super.tearDown() + } + + func testValidValues() async throws { + _ = try await database.execute( + sql: "INSERT INTO users (id, count, is_active, weight) VALUES (?, ?, ?, ?)", + parameters: ["1", 110, 0, 1.1111] + ) + + let user: User = try await database.get( + sql: "SELECT id, count, is_active, weight FROM users WHERE id = ?", + parameters: ["1"] + ) { cursor in + User( + id: try cursor.getString(name: "id"), + count: try cursor.getLong(name: "count"), + isActive: try cursor.getBoolean(name: "is_active"), + weight: try cursor.getDouble(name: "weight") + ) + } + + XCTAssertEqual(user.id, "1") + XCTAssertEqual(user.count, 110) + XCTAssertEqual(user.isActive, false) + XCTAssertEqual(user.weight, 1.1111) + } + + func testOptionalValues() async throws { + _ = try await database.execute( + sql: "INSERT INTO users (id, count, is_active, weight, description) VALUES (?, ?, ?, ?, ?)", + parameters: ["1", nil, nil, nil, nil, nil] + ) + + let user: UserOptional = try await database.get( + sql: "SELECT id, count, is_active, weight, description FROM users WHERE id = ?", + parameters: ["1"] + ) { cursor in + UserOptional( + id: try cursor.getString(name: "id"), + count: try cursor.getLongOptional(name: "count"), + isActive: try cursor.getBooleanOptional(name: "is_active"), + weight: try cursor.getDoubleOptional(name: "weight"), + description: try cursor.getStringOptional(name: "description") + ) + } + + XCTAssertEqual(user.id, "1") + XCTAssertNil(user.count) + XCTAssertNil(user.isActive) + XCTAssertNil(user.weight) + XCTAssertNil(user.description) + } +} From e5312f871b1094f1e4e9953454e87f82c434d69d Mon Sep 17 00:00:00 2001 From: DominicGBauer Date: Wed, 5 Feb 2025 17:09:33 +0200 Subject: [PATCH 2/4] feat: add SqlCursor function improvements --- CHANGELOG.md | 5 +++++ .../xcshareddata/swiftpm/Package.resolved | 4 ++-- Package.resolved | 4 ++-- Package.swift | 2 +- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3392b3..1011118 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 1.0.0-Beta.4 +* Allow cursor to use column name to get value by including the following functions that accept a column name parameter: +`getBoolean`,`getBooleanOptional`,`getString`,`getStringOptional`, `getLong`,`getLongOptional`, `getDouble`,`getDoubleOptional` + + ## 1.0.0-Beta.3 * BREAKING CHANGE: Update underlying powersync-kotlin package to BETA18.0 which requires transactions to become synchronous as opposed to asynchronous. diff --git a/Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index ca7e556..cf201e7 100644 --- a/Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/powersync-ja/powersync-kotlin.git", "state" : { - "revision" : "074001ad7d02b2b70c77168cdf4958c08dd6121b", - "version" : "1.0.0-BETA18.0" + "revision" : "7cd47ffc9dbec8fae4f9e067945ef2279015a90d", + "version" : "1.0.0-BETA20.0" } }, { diff --git a/Package.resolved b/Package.resolved index ee1fe27..599b4f8 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/powersync-ja/powersync-kotlin.git", "state" : { - "revision" : "074001ad7d02b2b70c77168cdf4958c08dd6121b", - "version" : "1.0.0-BETA18.0" + "revision" : "7cd47ffc9dbec8fae4f9e067945ef2279015a90d", + "version" : "1.0.0-BETA20.0" } }, { diff --git a/Package.swift b/Package.swift index 12396db..e56a71a 100644 --- a/Package.swift +++ b/Package.swift @@ -16,7 +16,7 @@ let package = Package( targets: ["PowerSync"]), ], dependencies: [ - .package(url: "https://github.com/powersync-ja/powersync-kotlin.git", exact: "1.0.0-BETA18.0"), + .package(url: "https://github.com/powersync-ja/powersync-kotlin.git", exact: "1.0.0-BETA20.0"), .package(url: "https://github.com/powersync-ja/powersync-sqlite-core-swift.git", "0.3.9"..<"0.4.0") ], targets: [ From 98bfe6979853bdb940a74b153024029f1ef1f38d Mon Sep 17 00:00:00 2001 From: DominicGBauer Date: Thu, 6 Feb 2025 09:57:03 +0200 Subject: [PATCH 3/4] docs: update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1011118..ed34017 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 1.0.0-Beta.4 * Allow cursor to use column name to get value by including the following functions that accept a column name parameter: `getBoolean`,`getBooleanOptional`,`getString`,`getStringOptional`, `getLong`,`getLongOptional`, `getDouble`,`getDoubleOptional` +* BREAKING CHANGE: This should not affect anyone but made `KotlinPowerSyncCredentials`, `KotlinPowerSyncDatabase` and `KotlinPowerSyncBackendConnector` as these should never have been public. ## 1.0.0-Beta.3 From 9cbdca6b12a3f7ac41dc78d632f778c9764e3d80 Mon Sep 17 00:00:00 2001 From: Dominic Gunther Bauer <46312751+DominicGBauer@users.noreply.github.com> Date: Thu, 6 Feb 2025 11:02:35 +0200 Subject: [PATCH 4/4] Update CHANGELOG.md Co-authored-by: stevensJourney <51082125+stevensJourney@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed34017..dcba8f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## 1.0.0-Beta.4 * Allow cursor to use column name to get value by including the following functions that accept a column name parameter: `getBoolean`,`getBooleanOptional`,`getString`,`getStringOptional`, `getLong`,`getLongOptional`, `getDouble`,`getDoubleOptional` -* BREAKING CHANGE: This should not affect anyone but made `KotlinPowerSyncCredentials`, `KotlinPowerSyncDatabase` and `KotlinPowerSyncBackendConnector` as these should never have been public. +* BREAKING CHANGE: This should not affect anyone but made `KotlinPowerSyncCredentials`, `KotlinPowerSyncDatabase` and `KotlinPowerSyncBackendConnector` private as these should never have been public. ## 1.0.0-Beta.3