Skip to content

Cursor Exceptions and Type Casts #23

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Feb 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## 1.0.0-Beta.7

* Fixed an issue where throwing exceptions in the query `mapper` could cause a runtime crash.
* Internally improved type casting.

## 1.0.0-Beta.6

* BREAKING CHANGE: `watch` queries are now throwable and therefore will need to be accompanied by a `try` e.g.
Expand Down
121 changes: 59 additions & 62 deletions Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,14 @@ import PowerSyncKotlin
final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol {
private let kotlinDatabase: PowerSyncKotlin.PowerSyncDatabase

var currentStatus: SyncStatus {
get { kotlinDatabase.currentStatus }
}
var currentStatus: SyncStatus { kotlinDatabase.currentStatus }

init(
schema: Schema,
dbFilename: String
) {
let factory = PowerSyncKotlin.DatabaseDriverFactory()
self.kotlinDatabase = PowerSyncDatabase(
kotlinDatabase = PowerSyncDatabase(
factory: factory,
schema: KotlinAdapter.Schema.toKotlin(schema),
dbFilename: dbFilename
Expand Down Expand Up @@ -65,85 +63,97 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol {
}

func execute(sql: String, parameters: [Any]?) async throws -> Int64 {
Int64(truncating: try await kotlinDatabase.execute(sql: sql, parameters: parameters))
try Int64(truncating: await kotlinDatabase.execute(sql: sql, parameters: parameters))
}

func get<RowType>(
sql: String,
parameters: [Any]?,
mapper: @escaping (SqlCursor) -> RowType
) async throws -> RowType {
try await kotlinDatabase.get(
try safeCast(await kotlinDatabase.get(
sql: sql,
parameters: parameters,
mapper: mapper
) as! RowType
), to: RowType.self)
}

func get<RowType>(
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
return try await wrapQueryCursorTyped(
mapper: mapper,
executor: { wrappedMapper in
try await self.kotlinDatabase.get(
sql: sql,
parameters: parameters,
mapper: wrappedMapper
)
},
resultType: RowType.self
)
}

func getAll<RowType>(
sql: String,
parameters: [Any]?,
mapper: @escaping (SqlCursor) -> RowType
) async throws -> [RowType] {
try await kotlinDatabase.getAll(
try safeCast(await kotlinDatabase.getAll(
sql: sql,
parameters: parameters,
mapper: mapper
) as! [RowType]
), to: [RowType].self)
}

func getAll<RowType>(
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]
try await wrapQueryCursorTyped(
mapper: mapper,
executor: { wrappedMapper in
try await self.kotlinDatabase.getAll(
sql: sql,
parameters: parameters,
mapper: wrappedMapper
)
},
resultType: [RowType].self
)
}

func getOptional<RowType>(
sql: String,
parameters: [Any]?,
mapper: @escaping (SqlCursor) -> RowType
) async throws -> RowType? {
try await kotlinDatabase.getOptional(
try safeCast(await kotlinDatabase.getOptional(
sql: sql,
parameters: parameters,
mapper: mapper
) as! RowType?
), to: RowType?.self)
}

func getOptional<RowType>(
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?
try await wrapQueryCursorTyped(
mapper: mapper,
executor: { wrappedMapper in
try await self.kotlinDatabase.getOptional(
sql: sql,
parameters: parameters,
mapper: wrappedMapper
)
},
resultType: RowType?.self
)
}

func watch<RowType>(
Expand All @@ -159,7 +169,7 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol {
parameters: parameters,
mapper: mapper
) {
continuation.yield(values as! [RowType])
try continuation.yield(safeCast(values, to: [RowType].self))
}
continuation.finish()
} catch {
Expand All @@ -177,14 +187,23 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol {
AsyncThrowingStream { continuation in
Task {
do {
for await values in try self.kotlinDatabase.watch(
var mapperError: Error?
for try await values in try self.kotlinDatabase.watch(
sql: sql,
parameters: parameters,
mapper: { cursor in
try! mapper(cursor)
}
mapper: { cursor in do {
return try mapper(cursor)
} catch {
mapperError = error
// The value here does not matter. We will throw the exception later
// This is not ideal, this is only a workaround until we expose fine grained access to Kotlin SDK internals.
return nil as RowType?
} }
) {
continuation.yield(values as! [RowType])
if mapperError != nil {
throw mapperError!
}
try continuation.yield(safeCast(values, to: [RowType].self))
}
continuation.finish()
} catch {
Expand All @@ -195,33 +214,11 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol {
}

public func writeTransaction<R>(callback: @escaping (any PowerSyncTransaction) throws -> R) async throws -> R {
return try await kotlinDatabase.writeTransaction(callback: TransactionCallback(callback: callback)) as! R
return try safeCast(await kotlinDatabase.writeTransaction(callback: TransactionCallback(callback: callback)), to: R.self)
}

public func readTransaction<R>(callback: @escaping (any PowerSyncTransaction) throws -> R) async throws -> R {
return try await kotlinDatabase.readTransaction(callback: TransactionCallback(callback: callback)) as! R
}
}

class TransactionCallback<R>: PowerSyncKotlin.ThrowableTransactionCallback {
let callback: (PowerSyncTransaction) throws -> R

init(callback: @escaping (PowerSyncTransaction) throws -> R) {
self.callback = callback
}

func execute(transaction: PowerSyncKotlin.PowerSyncTransaction) throws -> Any{
do {
return try callback(transaction)
} catch let error {
return PowerSyncKotlin.PowerSyncException(
message: error.localizedDescription,
cause: PowerSyncKotlin.KotlinThrowable(message: error.localizedDescription)
)
}
return try safeCast(await kotlinDatabase.readTransaction(callback: TransactionCallback(callback: callback)), to: R.self)
}
}

enum PowerSyncError: Error {
case invalidTransaction
}
19 changes: 19 additions & 0 deletions Sources/PowerSync/Kotlin/SafeCastError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
enum SafeCastError: Error, CustomStringConvertible {
case typeMismatch(expected: Any.Type, actual: Any?)

var description: String {
switch self {
case let .typeMismatch(expected, actual):
let actualType = actual.map { String(describing: type(of: $0)) } ?? "nil"
return "Type mismatch: Expected \(expected), but got \(actualType)."
}
}
}

internal func safeCast<T>(_ value: Any?, to type: T.Type) throws -> T {
if let castedValue = value as? T {
return castedValue
} else {
throw SafeCastError.typeMismatch(expected: type, actual: value)
}
}
35 changes: 35 additions & 0 deletions Sources/PowerSync/Kotlin/TransactionCallback.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import PowerSyncKotlin

class TransactionCallback<R>: PowerSyncKotlin.ThrowableTransactionCallback {
let callback: (PowerSyncTransaction) throws -> R

init(callback: @escaping (PowerSyncTransaction) throws -> R) {
self.callback = callback
}

// The Kotlin SDK does not gracefully handle exceptions thrown from Swift callbacks.
// If a Swift callback throws an exception, it results in a `BAD ACCESS` crash.
//
// To prevent this, we catch the exception and return it as a `PowerSyncException`,
// allowing Kotlin to propagate the error correctly.
//
// This approach is a workaround. Ideally, we should introduce an internal mechanism
// in the Kotlin SDK to handle errors from Swift more robustly.
//
// Currently, we wrap the public `PowerSyncDatabase` class in Kotlin, which limits our
// ability to handle exceptions cleanly. Instead, we should expose an internal implementation
// from a "core" package in Kotlin that provides better control over exception handling
// and other functionality—without modifying the public `PowerSyncDatabase` API to include
// Swift-specific logic.
func execute(transaction: PowerSyncKotlin.PowerSyncTransaction) throws -> Any {
do {
return try callback(transaction)
} catch {
return PowerSyncKotlin.PowerSyncException(
message: error.localizedDescription,
cause: PowerSyncKotlin.KotlinThrowable(message: error.localizedDescription)
)
}
}
}

51 changes: 51 additions & 0 deletions Sources/PowerSync/Kotlin/wrapQueryCursor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@

// The Kotlin SDK does not gracefully handle exceptions thrown from Swift callbacks.
// If a Swift callback throws an exception, it results in a `BAD ACCESS` crash.
//
// This approach is a workaround. Ideally, we should introduce an internal mechanism
// in the Kotlin SDK to handle errors from Swift more robustly.
//
// This hoists any exceptions thrown in a cursor mapper in order for the error to propagate correctly.
//
// Currently, we wrap the public `PowerSyncDatabase` class in Kotlin, which limits our
// ability to handle exceptions cleanly. Instead, we should expose an internal implementation
// from a "core" package in Kotlin that provides better control over exception handling
// and other functionality—without modifying the public `PowerSyncDatabase` API to include
// Swift-specific logic.
internal func wrapQueryCursor<RowType, ReturnType>(
mapper: @escaping (SqlCursor) throws -> RowType,
// The Kotlin APIs return the results as Any, we can explicitly cast internally
executor: @escaping (_ wrappedMapper: @escaping (SqlCursor) -> RowType?) async throws -> ReturnType
) async throws -> ReturnType {
var mapperException: Error?

// Wrapped version of the mapper that catches exceptions and sets `mapperException`
// In the case of an exception this will return an empty result.
let wrappedMapper: (SqlCursor) -> RowType? = { cursor in
do {
return try mapper(cursor)
} catch {
// Store the error in order to propagate it
mapperException = error
// Return nothing here. Kotlin should handle this as an empty object/row
return nil
}
}

let executionResult = try await executor(wrappedMapper)
if mapperException != nil {
// Allow propagating the error
throw mapperException!
}

return executionResult
}

internal func wrapQueryCursorTyped<RowType, ReturnType>(
mapper: @escaping (SqlCursor) throws -> RowType,
// The Kotlin APIs return the results as Any, we can explicitly cast internally
executor: @escaping (_ wrappedMapper: @escaping (SqlCursor) -> RowType?) async throws -> Any?,
resultType: ReturnType.Type
) async throws -> ReturnType {
return try safeCast(await wrapQueryCursor(mapper: mapper, executor: executor), to: resultType)
}
Loading