Skip to content

Commit abca0c9

Browse files
Cursor Exceptions and Type Casts (#23)
1 parent f50f8dd commit abca0c9

File tree

6 files changed

+260
-113
lines changed

6 files changed

+260
-113
lines changed

CHANGELOG.md

+5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## 1.0.0-Beta.7
4+
5+
* Fixed an issue where throwing exceptions in the query `mapper` could cause a runtime crash.
6+
* Internally improved type casting.
7+
38
## 1.0.0-Beta.6
49

510
* BREAKING CHANGE: `watch` queries are now throwable and therefore will need to be accompanied by a `try` e.g.

Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift

+59-62
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,14 @@ import PowerSyncKotlin
44
final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol {
55
private let kotlinDatabase: PowerSyncKotlin.PowerSyncDatabase
66

7-
var currentStatus: SyncStatus {
8-
get { kotlinDatabase.currentStatus }
9-
}
7+
var currentStatus: SyncStatus { kotlinDatabase.currentStatus }
108

119
init(
1210
schema: Schema,
1311
dbFilename: String
1412
) {
1513
let factory = PowerSyncKotlin.DatabaseDriverFactory()
16-
self.kotlinDatabase = PowerSyncDatabase(
14+
kotlinDatabase = PowerSyncDatabase(
1715
factory: factory,
1816
schema: KotlinAdapter.Schema.toKotlin(schema),
1917
dbFilename: dbFilename
@@ -65,85 +63,97 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol {
6563
}
6664

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

7169
func get<RowType>(
7270
sql: String,
7371
parameters: [Any]?,
7472
mapper: @escaping (SqlCursor) -> RowType
7573
) async throws -> RowType {
76-
try await kotlinDatabase.get(
74+
try safeCast(await kotlinDatabase.get(
7775
sql: sql,
7876
parameters: parameters,
7977
mapper: mapper
80-
) as! RowType
78+
), to: RowType.self)
8179
}
8280

8381
func get<RowType>(
8482
sql: String,
8583
parameters: [Any]?,
8684
mapper: @escaping (SqlCursor) throws -> RowType
8785
) async throws -> RowType {
88-
try await kotlinDatabase.get(
89-
sql: sql,
90-
parameters: parameters,
91-
mapper: { cursor in
92-
try! mapper(cursor)
93-
}
94-
) as! RowType
86+
return try await wrapQueryCursorTyped(
87+
mapper: mapper,
88+
executor: { wrappedMapper in
89+
try await self.kotlinDatabase.get(
90+
sql: sql,
91+
parameters: parameters,
92+
mapper: wrappedMapper
93+
)
94+
},
95+
resultType: RowType.self
96+
)
9597
}
9698

9799
func getAll<RowType>(
98100
sql: String,
99101
parameters: [Any]?,
100102
mapper: @escaping (SqlCursor) -> RowType
101103
) async throws -> [RowType] {
102-
try await kotlinDatabase.getAll(
104+
try safeCast(await kotlinDatabase.getAll(
103105
sql: sql,
104106
parameters: parameters,
105107
mapper: mapper
106-
) as! [RowType]
108+
), to: [RowType].self)
107109
}
108110

109111
func getAll<RowType>(
110112
sql: String,
111113
parameters: [Any]?,
112114
mapper: @escaping (SqlCursor) throws -> RowType
113115
) async throws -> [RowType] {
114-
try await kotlinDatabase.getAll(
115-
sql: sql,
116-
parameters: parameters,
117-
mapper: { cursor in
118-
try! mapper(cursor)
119-
}
120-
) as! [RowType]
116+
try await wrapQueryCursorTyped(
117+
mapper: mapper,
118+
executor: { wrappedMapper in
119+
try await self.kotlinDatabase.getAll(
120+
sql: sql,
121+
parameters: parameters,
122+
mapper: wrappedMapper
123+
)
124+
},
125+
resultType: [RowType].self
126+
)
121127
}
122128

123129
func getOptional<RowType>(
124130
sql: String,
125131
parameters: [Any]?,
126132
mapper: @escaping (SqlCursor) -> RowType
127133
) async throws -> RowType? {
128-
try await kotlinDatabase.getOptional(
134+
try safeCast(await kotlinDatabase.getOptional(
129135
sql: sql,
130136
parameters: parameters,
131137
mapper: mapper
132-
) as! RowType?
138+
), to: RowType?.self)
133139
}
134140

135141
func getOptional<RowType>(
136142
sql: String,
137143
parameters: [Any]?,
138144
mapper: @escaping (SqlCursor) throws -> RowType
139145
) async throws -> RowType? {
140-
try await kotlinDatabase.getOptional(
141-
sql: sql,
142-
parameters: parameters,
143-
mapper: { cursor in
144-
try! mapper(cursor)
145-
}
146-
) as! RowType?
146+
try await wrapQueryCursorTyped(
147+
mapper: mapper,
148+
executor: { wrappedMapper in
149+
try await self.kotlinDatabase.getOptional(
150+
sql: sql,
151+
parameters: parameters,
152+
mapper: wrappedMapper
153+
)
154+
},
155+
resultType: RowType?.self
156+
)
147157
}
148158

149159
func watch<RowType>(
@@ -159,7 +169,7 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol {
159169
parameters: parameters,
160170
mapper: mapper
161171
) {
162-
continuation.yield(values as! [RowType])
172+
try continuation.yield(safeCast(values, to: [RowType].self))
163173
}
164174
continuation.finish()
165175
} catch {
@@ -177,14 +187,23 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol {
177187
AsyncThrowingStream { continuation in
178188
Task {
179189
do {
180-
for await values in try self.kotlinDatabase.watch(
190+
var mapperError: Error?
191+
for try await values in try self.kotlinDatabase.watch(
181192
sql: sql,
182193
parameters: parameters,
183-
mapper: { cursor in
184-
try! mapper(cursor)
185-
}
194+
mapper: { cursor in do {
195+
return try mapper(cursor)
196+
} catch {
197+
mapperError = error
198+
// The value here does not matter. We will throw the exception later
199+
// This is not ideal, this is only a workaround until we expose fine grained access to Kotlin SDK internals.
200+
return nil as RowType?
201+
} }
186202
) {
187-
continuation.yield(values as! [RowType])
203+
if mapperError != nil {
204+
throw mapperError!
205+
}
206+
try continuation.yield(safeCast(values, to: [RowType].self))
188207
}
189208
continuation.finish()
190209
} catch {
@@ -195,33 +214,11 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol {
195214
}
196215

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

201220
public func readTransaction<R>(callback: @escaping (any PowerSyncTransaction) throws -> R) async throws -> R {
202-
return try await kotlinDatabase.readTransaction(callback: TransactionCallback(callback: callback)) as! R
203-
}
204-
}
205-
206-
class TransactionCallback<R>: PowerSyncKotlin.ThrowableTransactionCallback {
207-
let callback: (PowerSyncTransaction) throws -> R
208-
209-
init(callback: @escaping (PowerSyncTransaction) throws -> R) {
210-
self.callback = callback
211-
}
212-
213-
func execute(transaction: PowerSyncKotlin.PowerSyncTransaction) throws -> Any{
214-
do {
215-
return try callback(transaction)
216-
} catch let error {
217-
return PowerSyncKotlin.PowerSyncException(
218-
message: error.localizedDescription,
219-
cause: PowerSyncKotlin.KotlinThrowable(message: error.localizedDescription)
220-
)
221-
}
221+
return try safeCast(await kotlinDatabase.readTransaction(callback: TransactionCallback(callback: callback)), to: R.self)
222222
}
223223
}
224224

225-
enum PowerSyncError: Error {
226-
case invalidTransaction
227-
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
enum SafeCastError: Error, CustomStringConvertible {
2+
case typeMismatch(expected: Any.Type, actual: Any?)
3+
4+
var description: String {
5+
switch self {
6+
case let .typeMismatch(expected, actual):
7+
let actualType = actual.map { String(describing: type(of: $0)) } ?? "nil"
8+
return "Type mismatch: Expected \(expected), but got \(actualType)."
9+
}
10+
}
11+
}
12+
13+
internal func safeCast<T>(_ value: Any?, to type: T.Type) throws -> T {
14+
if let castedValue = value as? T {
15+
return castedValue
16+
} else {
17+
throw SafeCastError.typeMismatch(expected: type, actual: value)
18+
}
19+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import PowerSyncKotlin
2+
3+
class TransactionCallback<R>: PowerSyncKotlin.ThrowableTransactionCallback {
4+
let callback: (PowerSyncTransaction) throws -> R
5+
6+
init(callback: @escaping (PowerSyncTransaction) throws -> R) {
7+
self.callback = callback
8+
}
9+
10+
// The Kotlin SDK does not gracefully handle exceptions thrown from Swift callbacks.
11+
// If a Swift callback throws an exception, it results in a `BAD ACCESS` crash.
12+
//
13+
// To prevent this, we catch the exception and return it as a `PowerSyncException`,
14+
// allowing Kotlin to propagate the error correctly.
15+
//
16+
// This approach is a workaround. Ideally, we should introduce an internal mechanism
17+
// in the Kotlin SDK to handle errors from Swift more robustly.
18+
//
19+
// Currently, we wrap the public `PowerSyncDatabase` class in Kotlin, which limits our
20+
// ability to handle exceptions cleanly. Instead, we should expose an internal implementation
21+
// from a "core" package in Kotlin that provides better control over exception handling
22+
// and other functionality—without modifying the public `PowerSyncDatabase` API to include
23+
// Swift-specific logic.
24+
func execute(transaction: PowerSyncKotlin.PowerSyncTransaction) throws -> Any {
25+
do {
26+
return try callback(transaction)
27+
} catch {
28+
return PowerSyncKotlin.PowerSyncException(
29+
message: error.localizedDescription,
30+
cause: PowerSyncKotlin.KotlinThrowable(message: error.localizedDescription)
31+
)
32+
}
33+
}
34+
}
35+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
2+
// The Kotlin SDK does not gracefully handle exceptions thrown from Swift callbacks.
3+
// If a Swift callback throws an exception, it results in a `BAD ACCESS` crash.
4+
//
5+
// This approach is a workaround. Ideally, we should introduce an internal mechanism
6+
// in the Kotlin SDK to handle errors from Swift more robustly.
7+
//
8+
// This hoists any exceptions thrown in a cursor mapper in order for the error to propagate correctly.
9+
//
10+
// Currently, we wrap the public `PowerSyncDatabase` class in Kotlin, which limits our
11+
// ability to handle exceptions cleanly. Instead, we should expose an internal implementation
12+
// from a "core" package in Kotlin that provides better control over exception handling
13+
// and other functionality—without modifying the public `PowerSyncDatabase` API to include
14+
// Swift-specific logic.
15+
internal func wrapQueryCursor<RowType, ReturnType>(
16+
mapper: @escaping (SqlCursor) throws -> RowType,
17+
// The Kotlin APIs return the results as Any, we can explicitly cast internally
18+
executor: @escaping (_ wrappedMapper: @escaping (SqlCursor) -> RowType?) async throws -> ReturnType
19+
) async throws -> ReturnType {
20+
var mapperException: Error?
21+
22+
// Wrapped version of the mapper that catches exceptions and sets `mapperException`
23+
// In the case of an exception this will return an empty result.
24+
let wrappedMapper: (SqlCursor) -> RowType? = { cursor in
25+
do {
26+
return try mapper(cursor)
27+
} catch {
28+
// Store the error in order to propagate it
29+
mapperException = error
30+
// Return nothing here. Kotlin should handle this as an empty object/row
31+
return nil
32+
}
33+
}
34+
35+
let executionResult = try await executor(wrappedMapper)
36+
if mapperException != nil {
37+
// Allow propagating the error
38+
throw mapperException!
39+
}
40+
41+
return executionResult
42+
}
43+
44+
internal func wrapQueryCursorTyped<RowType, ReturnType>(
45+
mapper: @escaping (SqlCursor) throws -> RowType,
46+
// The Kotlin APIs return the results as Any, we can explicitly cast internally
47+
executor: @escaping (_ wrappedMapper: @escaping (SqlCursor) -> RowType?) async throws -> Any?,
48+
resultType: ReturnType.Type
49+
) async throws -> ReturnType {
50+
return try safeCast(await wrapQueryCursor(mapper: mapper, executor: executor), to: resultType)
51+
}

0 commit comments

Comments
 (0)