diff --git a/Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3d2f3e8..5000863 100644 --- a/Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -10,6 +10,15 @@ "version" : "0.6.7" } }, + { + "identity" : "powersync-kotlin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/powersync-ja/powersync-kotlin.git", + "state" : { + "revision" : "b547389faf77d0c79f30887b5d82489ee3f4de4b", + "version" : "1.0.0-BETA9.0" + } + }, { "identity" : "powersync-sqlite-core-swift", "kind" : "remoteSourceControl", diff --git a/Package.resolved b/Package.resolved index a325876..6be4ee5 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/powersync-ja/powersync-kotlin.git", "state" : { - "revision" : "7fb870f4530e5629b11f1a9b079644b200385985", - "version" : "1.0.0-BETA6.0" + "revision" : "b547389faf77d0c79f30887b5d82489ee3f4de4b", + "version" : "1.0.0-BETA9.0" } }, { diff --git a/Package.swift b/Package.swift index 6f6801b..13871d4 100644 --- a/Package.swift +++ b/Package.swift @@ -17,7 +17,7 @@ let package = Package( targets: ["PowerSyncSwift"]), ], dependencies: [ - .package(url: "https://github.com/powersync-ja/powersync-kotlin.git", exact: "1.0.0-BETA6.0"), + .package(url: "https://github.com/powersync-ja/powersync-kotlin.git", exact: "1.0.0-BETA9.0"), .package(url: "https://github.com/powersync-ja/powersync-sqlite-core-swift.git", "0.3.1"..<"0.4.0"), ], targets: [ diff --git a/Sources/PowerSyncSwift/Kotlin/KotlinPowerSyncDatabaseImpl.swift b/Sources/PowerSyncSwift/Kotlin/KotlinPowerSyncDatabaseImpl.swift index 5bfb048..984f0c5 100644 --- a/Sources/PowerSyncSwift/Kotlin/KotlinPowerSyncDatabaseImpl.swift +++ b/Sources/PowerSyncSwift/Kotlin/KotlinPowerSyncDatabaseImpl.swift @@ -3,11 +3,11 @@ import PowerSync final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { private let kotlinDatabase: PowerSync.PowerSyncDatabase - + var currentStatus: SyncStatus { get { kotlinDatabase.currentStatus } } - + init( schema: Schema, dbFilename: String @@ -19,55 +19,55 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { dbFilename: dbFilename ) } - + init(kotlinDatabase: KotlinPowerSyncDatabase) { self.kotlinDatabase = kotlinDatabase } - + func waitForFirstSync() async throws { try await kotlinDatabase.waitForFirstSync() } - + func connect( connector: PowerSyncBackendConnector, crudThrottleMs: Int64 = 1000, retryDelayMs: Int64 = 5000, params: [String: JsonParam?] = [:] ) async throws { - let connectorProxy = PowerSyncBackendConnectorAdapter(swiftBackendConnector: connector) - + let connectorAdapter = PowerSyncBackendConnectorAdapter(swiftBackendConnector: connector) + try await kotlinDatabase.connect( - connector: connectorProxy, + connector: connectorAdapter, crudThrottleMs: crudThrottleMs, retryDelayMs: retryDelayMs, params: params ) } - + func getCrudBatch(limit: Int32 = 100) async throws -> CrudBatch? { try await kotlinDatabase.getCrudBatch(limit: limit) } - + func getNextCrudTransaction() async throws -> CrudTransaction? { try await kotlinDatabase.getNextCrudTransaction() } - + func getPowerSyncVersion() async throws -> String { try await kotlinDatabase.getPowerSyncVersion() } - + func disconnect() async throws { try await kotlinDatabase.disconnect() } - + func disconnectAndClear(clearLocal: Bool = true) async throws { try await kotlinDatabase.disconnectAndClear(clearLocal: clearLocal) } - + func execute(sql: String, parameters: [Any]?) async throws -> Int64 { Int64(truncating: try await kotlinDatabase.execute(sql: sql, parameters: parameters)) } - + func get( sql: String, parameters: [Any]?, @@ -79,7 +79,7 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { mapper: mapper ) as! RowType } - + func getAll( sql: String, parameters: [Any]?, @@ -91,7 +91,7 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { mapper: mapper ) as! [RowType] } - + func getOptional( sql: String, parameters: [Any]?, @@ -103,7 +103,7 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { mapper: mapper ) as! RowType? } - + func watch( sql: String, parameters: [Any]?, @@ -122,31 +122,17 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { } } } - - func writeTransaction(callback: @escaping (any PowerSyncTransactionProtocol) async throws -> R) async throws -> R { - let wrappedCallback = SuspendTaskWrapper { [kotlinDatabase] in - // Create a wrapper that converts the KMP transaction to our Swift protocol - if let kmpTransaction = kotlinDatabase as? PowerSyncTransactionProtocol { - return try await callback(kmpTransaction) - } else { - throw PowerSyncError.invalidTransaction - } - } - - return try await kotlinDatabase.writeTransaction(callback: wrappedCallback) as! R - } - - func readTransaction(callback: @escaping (any PowerSyncTransactionProtocol) async throws -> R) async throws -> R { - let wrappedCallback = SuspendTaskWrapper { [kotlinDatabase] in - // Create a wrapper that converts the KMP transaction to our Swift protocol - if let kmpTransaction = kotlinDatabase as? PowerSyncTransactionProtocol { - return try await callback(kmpTransaction) - } else { - throw PowerSyncError.invalidTransaction - } - } - - return try await kotlinDatabase.readTransaction(callback: wrappedCallback) as! R + + public func writeTransaction(callback: @escaping (any PowerSyncTransaction) async throws -> R) async throws -> R { + return try await kotlinDatabase.writeTransaction(callback: SuspendTaskWrapper { transaction in + return try await callback(transaction) + }) as! R + } + + public func readTransaction(callback: @escaping (any PowerSyncTransaction) async throws -> R) async throws -> R { + return try await kotlinDatabase.writeTransaction(callback: SuspendTaskWrapper { transaction in + return try await callback(transaction) + }) as! R } } @@ -155,17 +141,16 @@ enum PowerSyncError: Error { } class SuspendTaskWrapper: KotlinSuspendFunction1 { - let handle: () async throws -> Any + let handle: (any PowerSyncTransaction) async throws -> Any - init(_ handle: @escaping () async throws -> Any) { + init(_ handle: @escaping (any PowerSyncTransaction) async throws -> Any) { self.handle = handle } - @MainActor func __invoke(p1: Any?, completionHandler: @escaping (Any?, Error?) -> Void) { Task { do { - let result = try await self.handle() + let result = try await self.handle(p1 as! any PowerSyncTransaction) completionHandler(result, nil) } catch { completionHandler(nil, error) diff --git a/Sources/PowerSyncSwift/PowerSyncTransactionProtocol.swift b/Sources/PowerSyncSwift/PowerSyncTransactionProtocol.swift deleted file mode 100644 index b4b4d81..0000000 --- a/Sources/PowerSyncSwift/PowerSyncTransactionProtocol.swift +++ /dev/null @@ -1,29 +0,0 @@ -public protocol PowerSyncTransactionProtocol { - /// Execute a write query and return the number of affected rows - func execute( - sql: String, - parameters: [Any]? - ) async throws -> Int64 - - /// Execute a read-only query and return a single optional result - func getOptional( - sql: String, - parameters: [Any]?, - mapper: @escaping (SqlCursor) -> RowType - ) async throws -> RowType? - - /// Execute a read-only query and return all results - func getAll( - sql: String, - parameters: [Any]?, - mapper: @escaping (SqlCursor) -> RowType - ) async throws -> [RowType] - - /// Execute a read-only query and return a single result - /// Throws if no result is found - func get( - sql: String, - parameters: [Any]?, - mapper: @escaping (SqlCursor) -> RowType - ) async throws -> RowType -} diff --git a/Sources/PowerSyncSwift/QueriesProtocol.swift b/Sources/PowerSyncSwift/QueriesProtocol.swift index 2aa69ee..1b3f9cf 100644 --- a/Sources/PowerSyncSwift/QueriesProtocol.swift +++ b/Sources/PowerSyncSwift/QueriesProtocol.swift @@ -1,5 +1,6 @@ import Foundation import Combine +import PowerSync public protocol Queries { /// Execute a write query (INSERT, UPDATE, DELETE) @@ -37,8 +38,42 @@ public protocol Queries { ) -> AsyncStream<[RowType]> /// Execute a write transaction with the given callback - func writeTransaction(callback: @escaping (PowerSyncTransactionProtocol) async throws -> R) async throws -> R - + func writeTransaction(callback: @escaping (any PowerSyncTransaction) async throws -> R) async throws -> R + /// Execute a read transaction with the given callback - func readTransaction(callback: @escaping (PowerSyncTransactionProtocol) async throws -> R) async throws -> R + func readTransaction(callback: @escaping (any PowerSyncTransaction) async throws -> R) async throws -> R +} + +extension Queries { + public func execute(_ sql: String) async throws -> Int64 { + return try await execute(sql: sql, parameters: []) + } + + public func get( + _ sql: String, + mapper: @escaping (SqlCursor) -> RowType + ) async throws -> RowType { + return try await get(sql: sql, parameters: [], mapper: mapper) + } + + public func getAll( + _ sql: String, + mapper: @escaping (SqlCursor) -> RowType + ) async throws -> [RowType] { + return try await getAll(sql: sql, parameters: [], mapper: mapper) + } + + public func getOptional( + _ sql: String, + mapper: @escaping (SqlCursor) -> RowType + ) async throws -> RowType? { + return try await getOptional(sql: sql, parameters: [], mapper: mapper) + } + + public func watch( + _ sql: String, + mapper: @escaping (SqlCursor) -> RowType + ) -> AsyncStream<[RowType]> { + return watch(sql: sql, parameters: [], mapper: mapper) + } } diff --git a/Tests/PowerSyncSwiftTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift b/Tests/PowerSyncSwiftTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift new file mode 100644 index 0000000..ef1e085 --- /dev/null +++ b/Tests/PowerSyncSwiftTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift @@ -0,0 +1,195 @@ +import XCTest +@testable import PowerSyncSwift + +final class KotlinPowerSyncDatabaseImplTests: 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("name"), + .text("email") + ]) + ]) + + 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 testInsertAndGet() async throws { + _ = try await database.execute( + sql: "INSERT INTO users (id, name, email) VALUES (?, ?, ?)", + parameters: ["1", "Test User", "test@example.com"] + ) + + let user: (String, String, String) = try await database.get( + sql: "SELECT id, name, email FROM users WHERE id = ?", + parameters: ["1"] + ) { cursor in + ( + cursor.getString(index: 0)!, + cursor.getString(index: 1)!, + cursor.getString(index: 2)! + ) + } + + XCTAssertEqual(user.0, "1") + XCTAssertEqual(user.1, "Test User") + XCTAssertEqual(user.2, "test@example.com") + } + + func testGetOptional() async throws { + let nonExistent: String? = try await database.getOptional( + sql: "SELECT name FROM users WHERE id = ?", + parameters: ["999"] + ) { cursor in + cursor.getString(index: 0)! + } + + XCTAssertNil(nonExistent) + + _ = try await database.execute( + sql: "INSERT INTO users (id, name, email) VALUES (?, ?, ?)", + parameters: ["1", "Test User", "test@example.com"] + ) + + let existing: String? = try await database.getOptional( + sql: "SELECT name FROM users WHERE id = ?", + parameters: ["1"] + ) { cursor in + cursor.getString(index: 0)! + } + + XCTAssertEqual(existing, "Test User") + } + + func testGetAll() async throws { + _ = try await database.execute( + sql: "INSERT INTO users (id, name, email) VALUES (?, ?, ?), (?, ?, ?)", + parameters: ["1", "User 1", "user1@example.com", "2", "User 2", "user2@example.com"] + ) + + let users: [(String, String)] = try await database.getAll( + sql: "SELECT id, name FROM users ORDER BY id", + parameters: nil + ) { cursor in + (cursor.getString(index: 0)!, cursor.getString(index: 1)!) + } + + XCTAssertEqual(users.count, 2) + XCTAssertEqual(users[0].0, "1") + XCTAssertEqual(users[0].1, "User 1") + XCTAssertEqual(users[1].0, "2") + XCTAssertEqual(users[1].1, "User 2") + } + + func testWatchTableChanges() async throws { + let expectation = XCTestExpectation(description: "Watch changes") + + // Create an actor to handle concurrent mutations + actor ResultsStore { + private var results: [[String]] = [] + + func append(_ names: [String]) { + results.append(names) + } + + func getResults() -> [[String]] { + results + } + + func count() -> Int { + results.count + } + } + + let resultsStore = ResultsStore() + + let stream = database.watch( + sql: "SELECT name FROM users ORDER BY id", + parameters: nil + ) { cursor in + cursor.getString(index: 0)! + } + + let watchTask = Task { + for await names in stream { + await resultsStore.append(names) + if await resultsStore.count() == 2 { + expectation.fulfill() + } + } + } + + _ = try await database.execute( + sql: "INSERT INTO users (id, name, email) VALUES (?, ?, ?)", + parameters: ["1", "User 1", "user1@example.com"] + ) + + _ = try await database.execute( + sql: "INSERT INTO users (id, name, email) VALUES (?, ?, ?)", + parameters: ["2", "User 2", "user2@example.com"] + ) + + await fulfillment(of: [expectation], timeout: 5) + watchTask.cancel() + + let finalResults = await resultsStore.getResults() + XCTAssertEqual(finalResults.count, 2) + XCTAssertEqual(finalResults[1], ["User 1", "User 2"]) + } + + func testWriteTransaction() async throws { + try await database.writeTransaction { transaction in + _ = try await transaction.execute( + sql: "INSERT INTO users (id, name, email) VALUES (?, ?, ?)", + parameters: ["1", "Test User", "test@example.com"] + ) + + _ = try await transaction.execute( + sql: "INSERT INTO users (id, name, email) VALUES (?, ?, ?)", + parameters: ["2", "Test User 2", "test2@example.com"] + ) + } + + + let result = try await database.get( + sql: "SELECT COUNT(*) FROM users", + parameters: [] + ) { cursor in + cursor.getLong(index: 0) + } + + XCTAssertEqual(result as! Int, 2) + } + + func testReadTransaction() async throws { + _ = try await database.execute( + sql: "INSERT INTO users (id, name, email) VALUES (?, ?, ?)", + parameters: ["1", "Test User", "test@example.com"] + ) + + + try await database.readTransaction { transaction in + let result = try await transaction.get( + sql: "SELECT COUNT(*) FROM users", + parameters: [] + ) { cursor in + cursor.getLong(index: 0) + } + + XCTAssertEqual(result as! Int, 1) + } + } +}