From 6c43c3e4cf8d2f5fd8666a8ea0f6e90f4be8d8d3 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 11 Mar 2026 00:27:29 +0700 Subject: [PATCH] feat: standardize plugin system patterns (Phase 1) --- CHANGELOG.md | 4 + .../ClickHousePlugin.swift | 25 +- Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift | 58 +--- .../MongoDBConnection.swift | 315 +++++++----------- .../MongoDBPluginDriver.swift | 21 +- .../MariaDBPluginConnection.swift | 233 ++++++------- .../MySQLDriverPlugin/MySQLPluginDriver.swift | 9 +- .../OracleDriverPlugin/OracleConnection.swift | 17 +- Plugins/OracleDriverPlugin/OraclePlugin.swift | 6 +- .../LibPQPluginConnection.swift | 150 ++++----- .../PostgreSQLPluginDriver.swift | 6 +- .../RedshiftPluginDriver.swift | 6 +- .../RedisCommandParser.swift | 13 +- .../RedisPluginConnection.swift | 217 +++++------- .../RedisDriverPlugin/RedisPluginDriver.swift | 16 +- Plugins/SQLiteDriverPlugin/SQLitePlugin.swift | 28 +- .../PluginConcurrencySupport.swift | 24 ++ .../TableProPluginKit/PluginDriverError.swift | 14 + .../TableProPluginKit/PluginQueryResult.swift | 5 +- .../Core/Plugins/PluginDriverAdapter.swift | 17 +- 20 files changed, 511 insertions(+), 673 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 434de352..4c71324c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Result truncation at 100K rows now reported to UI via `PluginQueryResult.isTruncated` instead of being silently discarded - DELETE and UPDATE queries using all columns in WHERE clause instead of just the primary key for PostgreSQL, Redshift, MSSQL, and ClickHouse - SSL/TLS always being enabled for MongoDB, Redis, and ClickHouse connections due to case mismatch in SSL mode string comparison (#249) - Redis sidebar click showing data briefly then going empty due to double-navigation race condition (#251) @@ -16,6 +17,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Unified error formatting across all database drivers via default `PluginDriverError.errorDescription`, removing 10 per-driver implementations +- Standardized async bridging: 5 queue-based drivers (MySQL, PostgreSQL, MongoDB, Redis, MSSQL) now use shared `pluginDispatchAsync` helper +- Added localization to remaining driver error messages (MySQL, PostgreSQL, ClickHouse, Oracle, Redis, MongoDB) - NoSQL query building moved from Core to MongoDB/Redis plugins via optional `PluginDatabaseDriver` protocol methods - Standardized parameter binding across all database drivers with improved default escaping (type-aware numeric handling, NUL byte stripping, NULL literal support) diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift index 5eb1a42b..32d6dfc0 100644 --- a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift +++ b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift @@ -28,11 +28,10 @@ final class ClickHousePlugin: NSObject, TableProPlugin, DriverPlugin { private struct ClickHouseError: Error, PluginDriverError { let message: String - var errorDescription: String? { "ClickHouse Error: \(message)" } var pluginErrorMessage: String { message } - static let notConnected = ClickHouseError(message: "Not connected to database") - static let connectionFailed = ClickHouseError(message: "Failed to establish connection") + static let notConnected = ClickHouseError(message: String(localized: "Not connected to database")) + static let connectionFailed = ClickHouseError(message: String(localized: "Failed to establish connection")) } // MARK: - Internal Query Result @@ -42,6 +41,7 @@ private struct CHQueryResult { let columnTypeNames: [String] let rows: [[String?]] let affectedRows: Int + let isTruncated: Bool } // MARK: - Plugin Driver @@ -139,7 +139,8 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { columnTypeNames: result.columnTypeNames, rows: result.rows, rowsAffected: result.affectedRows, - executionTime: executionTime + executionTime: executionTime, + isTruncated: result.isTruncated ) } @@ -159,7 +160,8 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { columnTypeNames: result.columnTypeNames, rows: result.rows, rowsAffected: result.affectedRows, - executionTime: executionTime + executionTime: executionTime, + isTruncated: result.isTruncated ) } @@ -584,7 +586,7 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { return parseTabSeparatedResponse(data) } - return CHQueryResult(columns: [], columnTypeNames: [], rows: [], affectedRows: 0) + return CHQueryResult(columns: [], columnTypeNames: [], rows: [], affectedRows: 0, isTruncated: false) } private func executeRawWithParams(_ query: String, params: [String: String?], queryId: String? = nil) async throws -> CHQueryResult { @@ -641,7 +643,7 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { return parseTabSeparatedResponse(data) } - return CHQueryResult(columns: [], columnTypeNames: [], rows: [], affectedRows: 0) + return CHQueryResult(columns: [], columnTypeNames: [], rows: [], affectedRows: 0, isTruncated: false) } private func buildRequest(query: String, database: String, queryId: String? = nil, params: [String: String?]? = nil) throws -> URLRequest { @@ -705,19 +707,20 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { private func parseTabSeparatedResponse(_ data: Data) -> CHQueryResult { guard let text = String(data: data, encoding: .utf8), !text.isEmpty else { - return CHQueryResult(columns: [], columnTypeNames: [], rows: [], affectedRows: 0) + return CHQueryResult(columns: [], columnTypeNames: [], rows: [], affectedRows: 0, isTruncated: false) } let lines = text.components(separatedBy: "\n") guard lines.count >= 2 else { - return CHQueryResult(columns: [], columnTypeNames: [], rows: [], affectedRows: 0) + return CHQueryResult(columns: [], columnTypeNames: [], rows: [], affectedRows: 0, isTruncated: false) } let columns = lines[0].components(separatedBy: "\t") let columnTypes = lines[1].components(separatedBy: "\t") var rows: [[String?]] = [] + var truncated = false for i in 2..= PluginRowLimits.defaultMax { + truncated = true break } } @@ -739,7 +743,8 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { columns: columns, columnTypeNames: columnTypes, rows: rows, - affectedRows: rows.count + affectedRows: rows.count, + isTruncated: truncated ) } diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index db79fe95..9e4dfeb8 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -79,6 +79,7 @@ private struct FreeTDSQueryResult { let columnTypeNames: [String] let rows: [[String?]] let affectedRows: Int + let isTruncated: Bool } private final class FreeTDSConnection: @unchecked Sendable { @@ -109,15 +110,8 @@ private final class FreeTDSConnection: @unchecked Sendable { } func connect() async throws { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - queue.async { [self] in - do { - try self.connectSync() - continuation.resume() - } catch { - continuation.resume(throwing: error) - } - } + try await pluginDispatchAsync(on: queue) { [self] in + try self.connectSync() } } @@ -153,17 +147,12 @@ private final class FreeTDSConnection: @unchecked Sendable { } func switchDatabase(_ database: String) async throws { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - queue.async { [self] in - guard let proc = self.dbproc else { - continuation.resume(throwing: MSSQLPluginError.notConnected) - return - } - if dbuse(proc, database) == FAIL { - continuation.resume(throwing: MSSQLPluginError.queryFailed("Cannot switch to database '\(database)'")) - } else { - continuation.resume() - } + try await pluginDispatchAsync(on: queue) { [self] in + guard let proc = self.dbproc else { + throw MSSQLPluginError.notConnected + } + if dbuse(proc, database) == FAIL { + throw MSSQLPluginError.queryFailed("Cannot switch to database '\(database)'") } } } @@ -185,15 +174,8 @@ private final class FreeTDSConnection: @unchecked Sendable { func executeQuery(_ query: String) async throws -> FreeTDSQueryResult { let queryToRun = String(query) - return try await withCheckedThrowingContinuation { [self] (cont: CheckedContinuation) in - queue.async { [self] in - do { - let result = try self.executeQuerySync(queryToRun) - cont.resume(returning: result) - } catch { - cont.resume(throwing: error) - } - } + return try await pluginDispatchAsync(on: queue) { [self] in + try self.executeQuerySync(queryToRun) } } @@ -217,6 +199,7 @@ private final class FreeTDSConnection: @unchecked Sendable { var allTypeNames: [String] = [] var allRows: [[String?]] = [] var firstResultSet = true + var truncated = false while true { let resCode = dbresults(proc) @@ -264,6 +247,7 @@ private final class FreeTDSConnection: @unchecked Sendable { } allRows.append(row) if allRows.count >= PluginRowLimits.defaultMax { + truncated = true break } } @@ -274,7 +258,8 @@ private final class FreeTDSConnection: @unchecked Sendable { columns: allColumns, columnTypeNames: allTypeNames, rows: allRows, - affectedRows: affectedRows + affectedRows: affectedRows, + isTruncated: truncated ) } @@ -396,7 +381,8 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { columnTypeNames: result.columnTypeNames, rows: result.rows, rowsAffected: result.affectedRows, - executionTime: Date().timeIntervalSince(startTime) + executionTime: Date().timeIntervalSince(startTime), + isTruncated: result.isTruncated ) } @@ -1043,18 +1029,10 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { // MARK: - Errors -enum MSSQLPluginError: LocalizedError { +enum MSSQLPluginError: Error { case connectionFailed(String) case notConnected case queryFailed(String) - - var errorDescription: String? { - switch self { - case .connectionFailed(let message): return "SQL Server Error: \(message)" - case .notConnected: return "SQL Server Error: Not connected to database" - case .queryFailed(let message): return "SQL Server Error: \(message)" - } - } } extension MSSQLPluginError: PluginDriverError { diff --git a/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift b/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift index c3ab90d0..0ea009c8 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift @@ -17,17 +17,15 @@ private let logger = Logger(subsystem: "com.TablePro", category: "MongoDBConnect // MARK: - Error Types -struct MongoDBError: Error, LocalizedError { +struct MongoDBError: Error { let code: UInt32 let message: String - var errorDescription: String? { "MongoDB Error \(code): \(message)" } - - static let notConnected = MongoDBError(code: 0, message: "Not connected to database") - static let connectionFailed = MongoDBError(code: 0, message: "Failed to establish connection") + static let notConnected = MongoDBError(code: 0, message: String(localized: "Not connected to database")) + static let connectionFailed = MongoDBError(code: 0, message: String(localized: "Failed to establish connection")) static let libmongocUnavailable = MongoDBError( code: 0, - message: "MongoDB support requires libmongoc. Run scripts/build-libmongoc.sh first." + message: String(localized: "MongoDB support requires libmongoc. Run scripts/build-libmongoc.sh first.") ) } @@ -212,53 +210,46 @@ final class MongoDBConnection: @unchecked Sendable { func connect() async throws { #if canImport(CLibMongoc) _ = Self.initOnce - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - queue.async { [self] in - let uriString = buildUri() - logger.debug("Connecting to MongoDB at \(self.host):\(self.port)") - - guard let newClient = mongoc_client_new(uriString) else { - logger.error("Failed to create MongoDB client") - continuation.resume(throwing: MongoDBError.connectionFailed) - return - } + try await pluginDispatchAsync(on: queue) { [self] in + let uriString = buildUri() + logger.debug("Connecting to MongoDB at \(self.host):\(self.port)") - // Verify connection with a ping - var error = bson_error_t() - guard let pingCmd = jsonToBson("{\"ping\": 1}") else { - mongoc_client_destroy(newClient) - continuation.resume(throwing: MongoDBError.connectionFailed) - return - } - defer { bson_destroy(pingCmd) } + guard let newClient = mongoc_client_new(uriString) else { + logger.error("Failed to create MongoDB client") + throw MongoDBError.connectionFailed + } - let reply = bson_new() - defer { bson_destroy(reply) } + var error = bson_error_t() + guard let pingCmd = jsonToBson("{\"ping\": 1}") else { + mongoc_client_destroy(newClient) + throw MongoDBError.connectionFailed + } + defer { bson_destroy(pingCmd) } - let dbName = database.isEmpty ? "admin" : database - let success = dbName.withCString { dbNamePtr in - mongoc_client_command_simple(newClient, dbNamePtr, pingCmd, nil, reply, &error) - } + let reply = bson_new() + defer { bson_destroy(reply) } - guard success else { - let errorMsg = bsonErrorMessage(&error) - mongoc_client_destroy(newClient) - logger.error("MongoDB ping failed: \(errorMsg)") - continuation.resume(throwing: MongoDBError(code: error.code, message: errorMsg)) - return - } + let dbName = database.isEmpty ? "admin" : database + let success = dbName.withCString { dbNamePtr in + mongoc_client_command_simple(newClient, dbNamePtr, pingCmd, nil, reply, &error) + } - self.client = newClient - let versionString = self.fetchServerVersionSync() + guard success else { + let errorMsg = bsonErrorMessage(&error) + mongoc_client_destroy(newClient) + logger.error("MongoDB ping failed: \(errorMsg)") + throw MongoDBError(code: error.code, message: errorMsg) + } - self.stateLock.lock() - self._cachedServerVersion = versionString - self._isConnected = true - self.stateLock.unlock() + self.client = newClient + let versionString = self.fetchServerVersionSync() - logger.info("Connected to MongoDB \(versionString ?? "unknown")") - continuation.resume() - } + self.stateLock.lock() + self._cachedServerVersion = versionString + self._isConnected = true + self.stateLock.unlock() + + logger.info("Connected to MongoDB \(versionString ?? "unknown")") } #else throw MongoDBError.libmongocUnavailable @@ -317,27 +308,23 @@ final class MongoDBConnection: @unchecked Sendable { func ping() async throws -> Bool { #if canImport(CLibMongoc) - return try await withCheckedThrowingContinuation { [self] (cont: CheckedContinuation) in - queue.async { [self] in - guard !isShuttingDown, let client = self.client else { - cont.resume(throwing: MongoDBError.notConnected) - return - } - var error = bson_error_t() - guard let command = jsonToBson("{\"ping\": 1}") else { - cont.resume(returning: false) - return - } - defer { bson_destroy(command) } - let reply = bson_new() - defer { bson_destroy(reply) } + return try await pluginDispatchAsync(on: queue) { [self] in + guard !isShuttingDown, let client = self.client else { + throw MongoDBError.notConnected + } + var error = bson_error_t() + guard let command = jsonToBson("{\"ping\": 1}") else { + return false + } + defer { bson_destroy(command) } + let reply = bson_new() + defer { bson_destroy(reply) } - let dbName = database.isEmpty ? "admin" : database - let ok = dbName.withCString { ptr in - mongoc_client_command_simple(client, ptr, command, nil, reply, &error) - } - cont.resume(returning: ok) + let dbName = database.isEmpty ? "admin" : database + let ok = dbName.withCString { ptr in + mongoc_client_command_simple(client, ptr, command, nil, reply, &error) } + return ok } #else throw MongoDBError.libmongocUnavailable @@ -358,21 +345,14 @@ final class MongoDBConnection: @unchecked Sendable { func runCommand(_ command: String, database: String? = nil) async throws -> [[String: Any]] { #if canImport(CLibMongoc) resetCancellation() - return try await withCheckedThrowingContinuation { [self] (cont: CheckedContinuation<[[String: Any]], Error>) in - queue.async { [self] in - guard !isShuttingDown, let client = self.client else { - cont.resume(throwing: MongoDBError.notConnected) - return - } - do { - try checkCancelled() - let result = try runCommandSync(client: client, command: command, database: database) - try checkCancelled() - cont.resume(returning: result) - } catch { - cont.resume(throwing: error) - } + return try await pluginDispatchAsync(on: queue) { [self] in + guard !isShuttingDown, let client = self.client else { + throw MongoDBError.notConnected } + try checkCancelled() + let result = try runCommandSync(client: client, command: command, database: database) + try checkCancelled() + return result } #else throw MongoDBError.libmongocUnavailable @@ -392,21 +372,15 @@ final class MongoDBConnection: @unchecked Sendable { ) async throws -> [[String: Any]] { #if canImport(CLibMongoc) resetCancellation() - return try await withCheckedThrowingContinuation { [self] (cont: CheckedContinuation<[[String: Any]], Error>) in - queue.async { [self] in - guard !isShuttingDown, let client = self.client else { - cont.resume(throwing: MongoDBError.notConnected) - return - } - do { - try checkCancelled() - let result = try findSync( - client: client, database: database, collection: collection, - filter: filter, sort: sort, projection: projection, skip: skip, limit: limit - ) - cont.resume(returning: result) - } catch { cont.resume(throwing: error) } + return try await pluginDispatchAsync(on: queue) { [self] in + guard !isShuttingDown, let client = self.client else { + throw MongoDBError.notConnected } + try checkCancelled() + return try findSync( + client: client, database: database, collection: collection, + filter: filter, sort: sort, projection: projection, skip: skip, limit: limit + ) } #else throw MongoDBError.libmongocUnavailable @@ -416,20 +390,14 @@ final class MongoDBConnection: @unchecked Sendable { func aggregate(database: String, collection: String, pipeline: String) async throws -> [[String: Any]] { #if canImport(CLibMongoc) resetCancellation() - return try await withCheckedThrowingContinuation { [self] (cont: CheckedContinuation<[[String: Any]], Error>) in - queue.async { [self] in - guard !isShuttingDown, let client = self.client else { - cont.resume(throwing: MongoDBError.notConnected) - return - } - do { - try checkCancelled() - let result = try aggregateSync( - client: client, database: database, collection: collection, pipeline: pipeline - ) - cont.resume(returning: result) - } catch { cont.resume(throwing: error) } + return try await pluginDispatchAsync(on: queue) { [self] in + guard !isShuttingDown, let client = self.client else { + throw MongoDBError.notConnected } + try checkCancelled() + return try aggregateSync( + client: client, database: database, collection: collection, pipeline: pipeline + ) } #else throw MongoDBError.libmongocUnavailable @@ -439,21 +407,16 @@ final class MongoDBConnection: @unchecked Sendable { func countDocuments(database: String, collection: String, filter: String) async throws -> Int64 { #if canImport(CLibMongoc) resetCancellation() - return try await withCheckedThrowingContinuation { [self] (cont: CheckedContinuation) in - queue.async { [self] in - guard !isShuttingDown, let client = self.client else { - cont.resume(throwing: MongoDBError.notConnected) - return - } - do { - try checkCancelled() - let count = try countDocumentsSync( - client: client, database: database, collection: collection, filter: filter - ) - try checkCancelled() - cont.resume(returning: count) - } catch { cont.resume(throwing: error) } + return try await pluginDispatchAsync(on: queue) { [self] in + guard !isShuttingDown, let client = self.client else { + throw MongoDBError.notConnected } + try checkCancelled() + let count = try countDocumentsSync( + client: client, database: database, collection: collection, filter: filter + ) + try checkCancelled() + return count } #else throw MongoDBError.libmongocUnavailable @@ -463,20 +426,14 @@ final class MongoDBConnection: @unchecked Sendable { func insertOne(database: String, collection: String, document: String) async throws -> String? { #if canImport(CLibMongoc) resetCancellation() - return try await withCheckedThrowingContinuation { [self] (cont: CheckedContinuation) in - queue.async { [self] in - guard !isShuttingDown, let client = self.client else { - cont.resume(throwing: MongoDBError.notConnected) - return - } - do { - try checkCancelled() - let id = try insertOneSync( - client: client, database: database, collection: collection, document: document - ) - cont.resume(returning: id) - } catch { cont.resume(throwing: error) } + return try await pluginDispatchAsync(on: queue) { [self] in + guard !isShuttingDown, let client = self.client else { + throw MongoDBError.notConnected } + try checkCancelled() + return try insertOneSync( + client: client, database: database, collection: collection, document: document + ) } #else throw MongoDBError.libmongocUnavailable @@ -486,20 +443,14 @@ final class MongoDBConnection: @unchecked Sendable { func updateOne(database: String, collection: String, filter: String, update: String) async throws -> Int64 { #if canImport(CLibMongoc) resetCancellation() - return try await withCheckedThrowingContinuation { [self] (cont: CheckedContinuation) in - queue.async { [self] in - guard !isShuttingDown, let client = self.client else { - cont.resume(throwing: MongoDBError.notConnected) - return - } - do { - try checkCancelled() - let modified = try updateOneSync( - client: client, database: database, collection: collection, filter: filter, update: update - ) - cont.resume(returning: modified) - } catch { cont.resume(throwing: error) } + return try await pluginDispatchAsync(on: queue) { [self] in + guard !isShuttingDown, let client = self.client else { + throw MongoDBError.notConnected } + try checkCancelled() + return try updateOneSync( + client: client, database: database, collection: collection, filter: filter, update: update + ) } #else throw MongoDBError.libmongocUnavailable @@ -509,20 +460,14 @@ final class MongoDBConnection: @unchecked Sendable { func deleteOne(database: String, collection: String, filter: String) async throws -> Int64 { #if canImport(CLibMongoc) resetCancellation() - return try await withCheckedThrowingContinuation { [self] (cont: CheckedContinuation) in - queue.async { [self] in - guard !isShuttingDown, let client = self.client else { - cont.resume(throwing: MongoDBError.notConnected) - return - } - do { - try checkCancelled() - let deleted = try deleteOneSync( - client: client, database: database, collection: collection, filter: filter - ) - cont.resume(returning: deleted) - } catch { cont.resume(throwing: error) } + return try await pluginDispatchAsync(on: queue) { [self] in + guard !isShuttingDown, let client = self.client else { + throw MongoDBError.notConnected } + try checkCancelled() + return try deleteOneSync( + client: client, database: database, collection: collection, filter: filter + ) } #else throw MongoDBError.libmongocUnavailable @@ -532,18 +477,12 @@ final class MongoDBConnection: @unchecked Sendable { func listDatabases() async throws -> [String] { #if canImport(CLibMongoc) resetCancellation() - return try await withCheckedThrowingContinuation { [self] (cont: CheckedContinuation<[String], Error>) in - queue.async { [self] in - guard !isShuttingDown, let client = self.client else { - cont.resume(throwing: MongoDBError.notConnected) - return - } - do { - try checkCancelled() - let dbs = try listDatabasesSync(client: client) - cont.resume(returning: dbs) - } catch { cont.resume(throwing: error) } + return try await pluginDispatchAsync(on: queue) { [self] in + guard !isShuttingDown, let client = self.client else { + throw MongoDBError.notConnected } + try checkCancelled() + return try listDatabasesSync(client: client) } #else throw MongoDBError.libmongocUnavailable @@ -553,18 +492,12 @@ final class MongoDBConnection: @unchecked Sendable { func listCollections(database: String) async throws -> [String] { #if canImport(CLibMongoc) resetCancellation() - return try await withCheckedThrowingContinuation { [self] (cont: CheckedContinuation<[String], Error>) in - queue.async { [self] in - guard !isShuttingDown, let client = self.client else { - cont.resume(throwing: MongoDBError.notConnected) - return - } - do { - try checkCancelled() - let cols = try listCollectionsSync(client: client, database: database) - cont.resume(returning: cols) - } catch { cont.resume(throwing: error) } + return try await pluginDispatchAsync(on: queue) { [self] in + guard !isShuttingDown, let client = self.client else { + throw MongoDBError.notConnected } + try checkCancelled() + return try listCollectionsSync(client: client, database: database) } #else throw MongoDBError.libmongocUnavailable @@ -574,20 +507,14 @@ final class MongoDBConnection: @unchecked Sendable { func listIndexes(database: String, collection: String) async throws -> [[String: Any]] { #if canImport(CLibMongoc) resetCancellation() - return try await withCheckedThrowingContinuation { [self] (cont: CheckedContinuation<[[String: Any]], Error>) in - queue.async { [self] in - guard !isShuttingDown, let client = self.client else { - cont.resume(throwing: MongoDBError.notConnected) - return - } - do { - try checkCancelled() - let indexes = try listIndexesSync( - client: client, database: database, collection: collection - ) - cont.resume(returning: indexes) - } catch { cont.resume(throwing: error) } + return try await pluginDispatchAsync(on: queue) { [self] in + guard !isShuttingDown, let client = self.client else { + throw MongoDBError.notConnected } + try checkCancelled() + return try listIndexesSync( + client: client, database: database, collection: collection + ) } #else throw MongoDBError.libmongocUnavailable diff --git a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift index 43807006..72db06b6 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift @@ -538,7 +538,8 @@ final class MongoDBPluginDriver: PluginDatabaseDriver { rows: [], rowsAffected: 0, executionTime: Date().timeIntervalSince(startTime) ) } - return buildPluginResult(from: docs, startTime: startTime) + let truncated = docs.count >= PluginRowLimits.defaultMax + return buildPluginResult(from: docs, startTime: startTime, isTruncated: truncated) case .findOne(let collection, let filter): let docs = try await conn.find( @@ -709,7 +710,11 @@ final class MongoDBPluginDriver: PluginDatabaseDriver { // MARK: - Result Building - private func buildPluginResult(from documents: [[String: Any]], startTime: Date) -> PluginQueryResult { + private func buildPluginResult( + from documents: [[String: Any]], + startTime: Date, + isTruncated: Bool = false + ) -> PluginQueryResult { if documents.isEmpty { return PluginQueryResult( columns: [], columnTypeNames: [], @@ -726,7 +731,8 @@ final class MongoDBPluginDriver: PluginDatabaseDriver { return PluginQueryResult( columns: columns, columnTypeNames: typeNames, rows: rows, rowsAffected: 0, - executionTime: Date().timeIntervalSince(startTime) + executionTime: Date().timeIntervalSince(startTime), + isTruncated: isTruncated ) } @@ -781,16 +787,9 @@ final class MongoDBPluginDriver: PluginDatabaseDriver { // MARK: - Error -enum MongoDBPluginError: Error, LocalizedError { +enum MongoDBPluginError: Error { case notConnected case unsupportedOperation - - var errorDescription: String? { - switch self { - case .notConnected: return "Not connected to MongoDB" - case .unsupportedOperation: return "Operation not supported for MongoDB" - } - } } extension MongoDBPluginError: PluginDriverError { diff --git a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift index 0e04fae9..fb07c291 100644 --- a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift +++ b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift @@ -20,24 +20,17 @@ private let logger = Logger(subsystem: "com.TablePro", category: "MariaDBPluginC // MARK: - Error Types -struct MariaDBPluginError: Error, LocalizedError { +struct MariaDBPluginError: Error { let code: UInt32 let message: String let sqlState: String? - var errorDescription: String? { - if let state = sqlState { - return "MySQL Error \(code) (\(state)): \(message)" - } - return "MySQL Error \(code): \(message)" - } - static let notConnected = MariaDBPluginError( - code: 0, message: "Not connected to database", sqlState: nil) + code: 0, message: String(localized: "Not connected to database"), sqlState: nil) static let connectionFailed = MariaDBPluginError( - code: 0, message: "Failed to establish connection", sqlState: nil) + code: 0, message: String(localized: "Failed to establish connection"), sqlState: nil) static let initFailed = MariaDBPluginError( - code: 0, message: "Failed to initialize MySQL client", sqlState: nil) + code: 0, message: String(localized: "Failed to initialize MySQL client"), sqlState: nil) } // MARK: - Query Result @@ -194,137 +187,131 @@ final class MariaDBPluginConnection: @unchecked Sendable { // MARK: - Connection Management func connect() async throws { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - queue.async { [self] in - guard let mysql = mysql_init(nil) else { - continuation.resume(throwing: MariaDBPluginError.initFailed) - return - } + try await pluginDispatchAsync(on: queue) { [self] in + guard let mysql = mysql_init(nil) else { + throw MariaDBPluginError.initFailed + } - self.mysql = mysql + self.mysql = mysql - var reconnect: my_bool = 0 - mysql_options(mysql, MYSQL_OPT_RECONNECT, &reconnect) + var reconnect: my_bool = 0 + mysql_options(mysql, MYSQL_OPT_RECONNECT, &reconnect) - var timeout: UInt32 = 10 - mysql_options(mysql, MYSQL_OPT_CONNECT_TIMEOUT, &timeout) + var timeout: UInt32 = 10 + mysql_options(mysql, MYSQL_OPT_CONNECT_TIMEOUT, &timeout) - var readTimeout: UInt32 = 30 - mysql_options(mysql, MYSQL_OPT_READ_TIMEOUT, &readTimeout) + var readTimeout: UInt32 = 30 + mysql_options(mysql, MYSQL_OPT_READ_TIMEOUT, &readTimeout) - var writeTimeout: UInt32 = 30 - mysql_options(mysql, MYSQL_OPT_WRITE_TIMEOUT, &writeTimeout) + var writeTimeout: UInt32 = 30 + mysql_options(mysql, MYSQL_OPT_WRITE_TIMEOUT, &writeTimeout) - var protocol_tcp = UInt32(MYSQL_PROTOCOL_TCP.rawValue) - mysql_options(mysql, MYSQL_OPT_PROTOCOL, &protocol_tcp) + var protocol_tcp = UInt32(MYSQL_PROTOCOL_TCP.rawValue) + mysql_options(mysql, MYSQL_OPT_PROTOCOL, &protocol_tcp) - // SSL/TLS configuration - switch self.sslConfig.mode { - case .disabled, .preferred: - var sslEnforce: my_bool = 0 - mysql_options(mysql, MYSQL_OPT_SSL_ENFORCE, &sslEnforce) - var sslVerify: my_bool = 0 - mysql_options(mysql, MYSQL_OPT_SSL_VERIFY_SERVER_CERT, &sslVerify) + switch self.sslConfig.mode { + case .disabled, .preferred: + var sslEnforce: my_bool = 0 + mysql_options(mysql, MYSQL_OPT_SSL_ENFORCE, &sslEnforce) + var sslVerify: my_bool = 0 + mysql_options(mysql, MYSQL_OPT_SSL_VERIFY_SERVER_CERT, &sslVerify) - case .required: - var sslEnforce: my_bool = 1 - mysql_options(mysql, MYSQL_OPT_SSL_ENFORCE, &sslEnforce) - var sslVerify: my_bool = 0 - mysql_options(mysql, MYSQL_OPT_SSL_VERIFY_SERVER_CERT, &sslVerify) + case .required: + var sslEnforce: my_bool = 1 + mysql_options(mysql, MYSQL_OPT_SSL_ENFORCE, &sslEnforce) + var sslVerify: my_bool = 0 + mysql_options(mysql, MYSQL_OPT_SSL_VERIFY_SERVER_CERT, &sslVerify) - case .verifyCa, .verifyIdentity: - var sslEnforce: my_bool = 1 - mysql_options(mysql, MYSQL_OPT_SSL_ENFORCE, &sslEnforce) - var sslVerify: my_bool = 1 - mysql_options(mysql, MYSQL_OPT_SSL_VERIFY_SERVER_CERT, &sslVerify) - } + case .verifyCa, .verifyIdentity: + var sslEnforce: my_bool = 1 + mysql_options(mysql, MYSQL_OPT_SSL_ENFORCE, &sslEnforce) + var sslVerify: my_bool = 1 + mysql_options(mysql, MYSQL_OPT_SSL_VERIFY_SERVER_CERT, &sslVerify) + } - if !self.sslConfig.caCertificatePath.isEmpty { - _ = self.sslConfig.caCertificatePath.withCString { path in - mysql_options(mysql, MYSQL_OPT_SSL_CA, path) - } + if !self.sslConfig.caCertificatePath.isEmpty { + _ = self.sslConfig.caCertificatePath.withCString { path in + mysql_options(mysql, MYSQL_OPT_SSL_CA, path) } - if !self.sslConfig.clientCertificatePath.isEmpty { - _ = self.sslConfig.clientCertificatePath.withCString { path in - mysql_options(mysql, MYSQL_OPT_SSL_CERT, path) - } + } + if !self.sslConfig.clientCertificatePath.isEmpty { + _ = self.sslConfig.clientCertificatePath.withCString { path in + mysql_options(mysql, MYSQL_OPT_SSL_CERT, path) } - if !self.sslConfig.clientKeyPath.isEmpty { - _ = self.sslConfig.clientKeyPath.withCString { path in - mysql_options(mysql, MYSQL_OPT_SSL_KEY, path) - } + } + if !self.sslConfig.clientKeyPath.isEmpty { + _ = self.sslConfig.clientKeyPath.withCString { path in + mysql_options(mysql, MYSQL_OPT_SSL_KEY, path) } + } - mysql_options(mysql, MYSQL_SET_CHARSET_NAME, "utf8mb4") + mysql_options(mysql, MYSQL_SET_CHARSET_NAME, "utf8mb4") - let dbToUse = self.database.isEmpty ? nil : self.database - let passToUse = self.password + let dbToUse = self.database.isEmpty ? nil : self.database + let passToUse = self.password - let result: UnsafeMutablePointer? + let result: UnsafeMutablePointer? - if let db = dbToUse, let pass = passToUse { - result = self.host.withCString { hostPtr in - self.user.withCString { userPtr in - pass.withCString { passPtr in - db.withCString { dbPtr in - mysql_real_connect( - mysql, hostPtr, userPtr, passPtr, dbPtr, - self.port, nil, 0 - ) - } - } - } - } - } else if let db = dbToUse { - result = self.host.withCString { hostPtr in - self.user.withCString { userPtr in + if let db = dbToUse, let pass = passToUse { + result = self.host.withCString { hostPtr in + self.user.withCString { userPtr in + pass.withCString { passPtr in db.withCString { dbPtr in mysql_real_connect( - mysql, hostPtr, userPtr, nil, dbPtr, + mysql, hostPtr, userPtr, passPtr, dbPtr, self.port, nil, 0 ) } } } - } else if let pass = passToUse { - result = self.host.withCString { hostPtr in - self.user.withCString { userPtr in - pass.withCString { passPtr in - mysql_real_connect( - mysql, hostPtr, userPtr, passPtr, nil, - self.port, nil, 0 - ) - } + } + } else if let db = dbToUse { + result = self.host.withCString { hostPtr in + self.user.withCString { userPtr in + db.withCString { dbPtr in + mysql_real_connect( + mysql, hostPtr, userPtr, nil, dbPtr, + self.port, nil, 0 + ) } } - } else { - result = self.host.withCString { hostPtr in - self.user.withCString { userPtr in + } + } else if let pass = passToUse { + result = self.host.withCString { hostPtr in + self.user.withCString { userPtr in + pass.withCString { passPtr in mysql_real_connect( - mysql, hostPtr, userPtr, nil, nil, + mysql, hostPtr, userPtr, passPtr, nil, self.port, nil, 0 ) } } } - - if result == nil { - let error = self.getError() - mysql_close(mysql) - self.mysql = nil - continuation.resume(throwing: error) - return + } else { + result = self.host.withCString { hostPtr in + self.user.withCString { userPtr in + mysql_real_connect( + mysql, hostPtr, userPtr, nil, nil, + self.port, nil, 0 + ) + } } + } - if let versionPtr = mysql_get_server_info(mysql) { - self._cachedServerVersion = String(cString: versionPtr) - } + if result == nil { + let error = self.getError() + mysql_close(mysql) + self.mysql = nil + throw error + } - self.stateLock.lock() - self._isConnected = true - self.stateLock.unlock() - continuation.resume() + if let versionPtr = mysql_get_server_info(mysql) { + self._cachedServerVersion = String(cString: versionPtr) } + + self.stateLock.lock() + self._isConnected = true + self.stateLock.unlock() } } @@ -391,20 +378,9 @@ final class MariaDBPluginConnection: @unchecked Sendable { func executeQuery(_ query: String) async throws -> MariaDBPluginQueryResult { let queryToRun = String(query) - return try await withCheckedThrowingContinuation { [self] (cont: CheckedContinuation) in - queue.async { [self] in - guard !isShuttingDown else { - cont.resume(throwing: MariaDBPluginError.notConnected) - return - } - - do { - let result = try executeQuerySync(queryToRun) - cont.resume(returning: result) - } catch { - cont.resume(throwing: error) - } - } + return try await pluginDispatchAsync(on: queue) { [self] in + guard !isShuttingDown else { throw MariaDBPluginError.notConnected } + return try executeQuerySync(queryToRun) } } @@ -412,20 +388,9 @@ final class MariaDBPluginConnection: @unchecked Sendable { let queryToRun = String(query) let params = parameters - return try await withCheckedThrowingContinuation { [self] (cont: CheckedContinuation) in - queue.async { [self] in - guard !isShuttingDown else { - cont.resume(throwing: MariaDBPluginError.notConnected) - return - } - - do { - let result = try executeParameterizedQuerySync(queryToRun, parameters: params) - cont.resume(returning: result) - } catch { - cont.resume(throwing: error) - } - } + return try await pluginDispatchAsync(on: queue) { [self] in + guard !isShuttingDown else { throw MariaDBPluginError.notConnected } + return try executeParameterizedQuerySync(queryToRun, parameters: params) } } diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index 431a58df..966152f9 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -92,7 +92,8 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { columnTypeNames: result.columnTypeNames, rows: result.rows, rowsAffected: Int(result.affectedRows), - executionTime: Date().timeIntervalSince(startTime) + executionTime: Date().timeIntervalSince(startTime), + isTruncated: result.isTruncated ) } @@ -120,7 +121,8 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { columnTypeNames: Array(repeating: "TEXT", count: columns.count), rows: [], rowsAffected: Int(result.affectedRows), - executionTime: Date().timeIntervalSince(startTime) + executionTime: Date().timeIntervalSince(startTime), + isTruncated: result.isTruncated ) } } @@ -130,7 +132,8 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { columnTypeNames: result.columnTypeNames, rows: result.rows, rowsAffected: Int(result.affectedRows), - executionTime: Date().timeIntervalSince(startTime) + executionTime: Date().timeIntervalSince(startTime), + isTruncated: result.isTruncated ) } catch let error as MariaDBPluginError where !isRetry && isConnectionLostError(error) { try await reconnect() diff --git a/Plugins/OracleDriverPlugin/OracleConnection.swift b/Plugins/OracleDriverPlugin/OracleConnection.swift index 79edfdcd..75a02ad7 100644 --- a/Plugins/OracleDriverPlugin/OracleConnection.swift +++ b/Plugins/OracleDriverPlugin/OracleConnection.swift @@ -17,14 +17,12 @@ private let osLogger = Logger(subsystem: "com.TablePro", category: "OracleConnec // MARK: - Error Types -struct OracleError: Error, LocalizedError { +struct OracleError: Error { let message: String - var errorDescription: String? { "Oracle Error: \(message)" } - - static let notConnected = OracleError(message: "Not connected to database") - static let connectionFailed = OracleError(message: "Failed to establish connection") - static let queryFailed = OracleError(message: "Query execution failed") + static let notConnected = OracleError(message: String(localized: "Not connected to database")) + static let connectionFailed = OracleError(message: String(localized: "Failed to establish connection")) + static let queryFailed = OracleError(message: String(localized: "Query execution failed")) } extension OracleError: PluginDriverError { @@ -38,6 +36,7 @@ struct OracleQueryResult { let columnTypeNames: [String] let rows: [[String?]] let affectedRows: Int + let isTruncated: Bool } // MARK: - Connection Class @@ -158,6 +157,7 @@ final class OracleConnectionWrapper: @unchecked Sendable { var columnTypeNames: [String] = [] var allRows: [[String?]] = [] var didReadTypes = false + var truncated = false for try await row in stream { var rowValues: [String?] = [] @@ -174,11 +174,11 @@ final class OracleConnectionWrapper: @unchecked Sendable { didReadTypes = true allRows.append(rowValues) if allRows.count >= PluginRowLimits.defaultMax { + truncated = true break } } - // If no rows were returned, fill type names with "unknown" if !didReadTypes { columnTypeNames = Array(repeating: "unknown", count: columns.count) } @@ -187,7 +187,8 @@ final class OracleConnectionWrapper: @unchecked Sendable { columns: columns, columnTypeNames: columnTypeNames, rows: allRows, - affectedRows: allRows.count + affectedRows: allRows.count, + isTruncated: truncated ) } catch let sqlError as OracleSQLError { let detail = sqlError.serverInfo?.message ?? sqlError.description diff --git a/Plugins/OracleDriverPlugin/OraclePlugin.swift b/Plugins/OracleDriverPlugin/OraclePlugin.swift index 171af4db..7e247910 100644 --- a/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -121,7 +121,8 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { columns: colNames, columnTypeNames: colTypes, rows: [], - affectedRows: 0 + affectedRows: 0, + isTruncated: false ) } } @@ -133,7 +134,8 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { columnTypeNames: result.columnTypeNames, rows: result.rows, rowsAffected: result.affectedRows, - executionTime: executionTime + executionTime: executionTime, + isTruncated: result.isTruncated ) } diff --git a/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift b/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift index e4b52c7a..13558b1a 100644 --- a/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift +++ b/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift @@ -49,26 +49,15 @@ struct PQSSLConfig { // MARK: - Error Types -struct LibPQPluginError: Error, LocalizedError { +struct LibPQPluginError: Error { let message: String let sqlState: String? let detail: String? - var errorDescription: String? { - var desc = "PostgreSQL Error: \(message)" - if let state = sqlState { - desc += " (SQLSTATE: \(state))" - } - if let detail = detail, !detail.isEmpty { - desc += "\nDetail: \(detail)" - } - return desc - } - static let notConnected = LibPQPluginError( - message: "Not connected to database", sqlState: nil, detail: nil) + message: String(localized: "Not connected to database"), sqlState: nil, detail: nil) static let connectionFailed = LibPQPluginError( - message: "Failed to establish connection", sqlState: nil, detail: nil) + message: String(localized: "Failed to establish connection"), sqlState: nil, detail: nil) } // MARK: - Query Result @@ -193,69 +182,64 @@ final class LibPQPluginConnection: @unchecked Sendable { // MARK: - Connection Management func connect() async throws { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - queue.async { [self] in - func escapeConnParam(_ value: String) -> String { - value.replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "'", with: "\\'") - } - - var connStr = "host='\(escapeConnParam(host))' port='\(port)' dbname='\(escapeConnParam(database))' connect_timeout='10'" + try await pluginDispatchAsync(on: queue) { [self] in + func escapeConnParam(_ value: String) -> String { + value.replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "'", with: "\\'") + } - if !user.isEmpty { - connStr += " user='\(escapeConnParam(user))'" - } + var connStr = "host='\(escapeConnParam(host))' port='\(port)' dbname='\(escapeConnParam(database))' connect_timeout='10'" - if let password = password, !password.isEmpty { - connStr += " password='\(escapeConnParam(password))'" - } + if !user.isEmpty { + connStr += " user='\(escapeConnParam(user))'" + } - connStr += " sslmode='\(sslConfig.libpqSslMode)'" + if let password = password, !password.isEmpty { + connStr += " password='\(escapeConnParam(password))'" + } - if sslConfig.verifiesCertificate, !sslConfig.caCertificatePath.isEmpty { - connStr += " sslrootcert='\(escapeConnParam(sslConfig.caCertificatePath))'" - } - if !sslConfig.clientCertificatePath.isEmpty { - connStr += " sslcert='\(escapeConnParam(sslConfig.clientCertificatePath))'" - } - if !sslConfig.clientKeyPath.isEmpty { - connStr += " sslkey='\(escapeConnParam(sslConfig.clientKeyPath))'" - } + connStr += " sslmode='\(sslConfig.libpqSslMode)'" - let connection = connStr.withCString { cStr in - PQconnectdb(cStr) - } + if sslConfig.verifiesCertificate, !sslConfig.caCertificatePath.isEmpty { + connStr += " sslrootcert='\(escapeConnParam(sslConfig.caCertificatePath))'" + } + if !sslConfig.clientCertificatePath.isEmpty { + connStr += " sslcert='\(escapeConnParam(sslConfig.clientCertificatePath))'" + } + if !sslConfig.clientKeyPath.isEmpty { + connStr += " sslkey='\(escapeConnParam(sslConfig.clientKeyPath))'" + } - guard let connection = connection else { - continuation.resume(throwing: LibPQPluginError.connectionFailed) - return - } + let connection = connStr.withCString { cStr in + PQconnectdb(cStr) + } - if PQstatus(connection) != CONNECTION_OK { - let error = self.getError(from: connection) - PQfinish(connection) - continuation.resume(throwing: error) - return - } + guard let connection = connection else { + throw LibPQPluginError.connectionFailed + } - _ = "SET client_encoding TO 'UTF8'".withCString { cStr in - PQexec(connection, cStr) - } + if PQstatus(connection) != CONNECTION_OK { + let error = self.getError(from: connection) + PQfinish(connection) + throw error + } - let version = PQserverVersion(connection) - if version > 0 { - let major = version / 10_000 - let minor = (version / 100) % 100 - let revision = version % 100 - self._cachedServerVersion = "\(major).\(minor).\(revision)" - } + _ = "SET client_encoding TO 'UTF8'".withCString { cStr in + PQexec(connection, cStr) + } - self.stateLock.lock() - self.conn = connection - self._isConnected = true - self.stateLock.unlock() - continuation.resume() + let version = PQserverVersion(connection) + if version > 0 { + let major = version / 10_000 + let minor = (version / 100) % 100 + let revision = version % 100 + self._cachedServerVersion = "\(major).\(minor).\(revision)" } + + self.stateLock.lock() + self.conn = connection + self._isConnected = true + self.stateLock.unlock() } } @@ -299,20 +283,9 @@ final class LibPQPluginConnection: @unchecked Sendable { func executeQuery(_ query: String) async throws -> LibPQPluginQueryResult { let queryToRun = String(query) - return try await withCheckedThrowingContinuation { [self] (cont: CheckedContinuation) in - queue.async { [self] in - guard !isShuttingDown else { - cont.resume(throwing: LibPQPluginError.notConnected) - return - } - - do { - let result = try executeQuerySync(queryToRun) - cont.resume(returning: result) - } catch { - cont.resume(throwing: error) - } - } + return try await pluginDispatchAsync(on: queue) { [self] in + guard !isShuttingDown else { throw LibPQPluginError.notConnected } + return try executeQuerySync(queryToRun) } } @@ -320,20 +293,9 @@ final class LibPQPluginConnection: @unchecked Sendable { let queryToRun = String(query) let params = parameters - return try await withCheckedThrowingContinuation { [self] (cont: CheckedContinuation) in - queue.async { [self] in - guard !isShuttingDown else { - cont.resume(throwing: LibPQPluginError.notConnected) - return - } - - do { - let result = try executeParameterizedQuerySync(queryToRun, parameters: params) - cont.resume(returning: result) - } catch { - cont.resume(throwing: error) - } - } + return try await pluginDispatchAsync(on: queue) { [self] in + guard !isShuttingDown else { throw LibPQPluginError.notConnected } + return try executeParameterizedQuerySync(queryToRun, parameters: params) } } diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index 02c448ea..35994236 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -91,7 +91,8 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { columnTypeNames: result.columnTypeNames, rows: result.rows, rowsAffected: result.affectedRows, - executionTime: Date().timeIntervalSince(startTime) + executionTime: Date().timeIntervalSince(startTime), + isTruncated: result.isTruncated ) } catch let error as NSError where !isRetry && isConnectionLostError(error) { try await reconnect() @@ -111,7 +112,8 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { columnTypeNames: result.columnTypeNames, rows: result.rows, rowsAffected: result.affectedRows, - executionTime: Date().timeIntervalSince(startTime) + executionTime: Date().timeIntervalSince(startTime), + isTruncated: result.isTruncated ) } diff --git a/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift index c85e4c70..48c3d54c 100644 --- a/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift @@ -91,7 +91,8 @@ final class RedshiftPluginDriver: PluginDatabaseDriver, @unchecked Sendable { columnTypeNames: result.columnTypeNames, rows: result.rows, rowsAffected: result.affectedRows, - executionTime: Date().timeIntervalSince(startTime) + executionTime: Date().timeIntervalSince(startTime), + isTruncated: result.isTruncated ) } catch let error as NSError where !isRetry && isConnectionLostError(error) { try await reconnect() @@ -110,7 +111,8 @@ final class RedshiftPluginDriver: PluginDatabaseDriver, @unchecked Sendable { columnTypeNames: result.columnTypeNames, rows: result.rows, rowsAffected: result.affectedRows, - executionTime: Date().timeIntervalSince(startTime) + executionTime: Date().timeIntervalSince(startTime), + isTruncated: result.isTruncated ) } diff --git a/Plugins/RedisDriverPlugin/RedisCommandParser.swift b/Plugins/RedisDriverPlugin/RedisCommandParser.swift index 82435b28..d53a564b 100644 --- a/Plugins/RedisDriverPlugin/RedisCommandParser.swift +++ b/Plugins/RedisDriverPlugin/RedisCommandParser.swift @@ -78,21 +78,10 @@ struct RedisSetOptions { } /// Error from parsing Redis CLI syntax -enum RedisParseError: Error, LocalizedError { +enum RedisParseError: Error { case emptySyntax case invalidArgument(String) case missingArgument(String) - - var errorDescription: String? { - switch self { - case .emptySyntax: - return String(localized: "Empty Redis command") - case .invalidArgument(let msg): - return String(localized: "Invalid argument: \(msg)") - case .missingArgument(let msg): - return String(localized: "Missing argument: \(msg)") - } - } } extension RedisParseError: PluginDriverError { diff --git a/Plugins/RedisDriverPlugin/RedisPluginConnection.swift b/Plugins/RedisDriverPlugin/RedisPluginConnection.swift index 0b45413f..2d77a488 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginConnection.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginConnection.swift @@ -75,17 +75,15 @@ enum RedisReply { // MARK: - Error Type -struct RedisPluginError: Error, LocalizedError { +struct RedisPluginError: Error { let code: Int let message: String - var errorDescription: String? { "Redis Error \(code): \(message)" } - - static let notConnected = RedisPluginError(code: 0, message: "Not connected to Redis") - static let connectionFailed = RedisPluginError(code: 0, message: "Failed to establish connection") + static let notConnected = RedisPluginError(code: 0, message: String(localized: "Not connected to Redis")) + static let connectionFailed = RedisPluginError(code: 0, message: String(localized: "Failed to establish connection")) static let hiredisUnavailable = RedisPluginError( code: 0, - message: "Redis support requires hiredis. Run scripts/build-hiredis.sh first." + message: String(localized: "Redis support requires hiredis. Run scripts/build-hiredis.sh first.") ) } @@ -186,102 +184,88 @@ final class RedisPluginConnection: @unchecked Sendable { func connect() async throws { #if canImport(CRedis) _ = Self.initOnce - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - queue.async { [self] in - logger.debug("Connecting to Redis at \(self.host):\(self.port)") - - guard let ctx = redisConnect(host, Int32(port)) else { - logger.error("Failed to create Redis context") - continuation.resume(throwing: RedisPluginError.connectionFailed) - return - } + try await pluginDispatchAsync(on: queue) { [self] in + logger.debug("Connecting to Redis at \(self.host):\(self.port)") - if ctx.pointee.err != 0 { - let errMsg = withUnsafePointer(to: &ctx.pointee.errstr) { ptr in - ptr.withMemoryRebound(to: CChar.self, capacity: 128) { String(cString: $0) } - } - logger.error("Redis connection error: \(errMsg)") - let errCode = Int(ctx.pointee.err) - redisFree(ctx) - continuation.resume(throwing: RedisPluginError(code: errCode, message: errMsg)) - return - } - - self.context = ctx + guard let ctx = redisConnect(host, Int32(port)) else { + logger.error("Failed to create Redis context") + throw RedisPluginError.connectionFailed + } - if sslConfig.isEnabled { - do { - try connectSSL(ctx) - } catch { - redisFree(ctx) - self.context = nil - continuation.resume(throwing: error) - return - } + if ctx.pointee.err != 0 { + let errMsg = withUnsafePointer(to: &ctx.pointee.errstr) { ptr in + ptr.withMemoryRebound(to: CChar.self, capacity: 128) { String(cString: $0) } } + logger.error("Redis connection error: \(errMsg)") + let errCode = Int(ctx.pointee.err) + redisFree(ctx) + throw RedisPluginError(code: errCode, message: errMsg) + } - if let password = password, !password.isEmpty { - do { - let reply = try executeCommandSync(["AUTH", password]) - if case .error(let msg) = reply { - redisFree(ctx) - self.context = nil - continuation.resume(throwing: RedisPluginError(code: 1, message: "AUTH failed: \(msg)")) - return - } - } catch { - redisFree(ctx) - self.context = nil - continuation.resume(throwing: error) - return - } + self.context = ctx + + if sslConfig.isEnabled { + do { + try connectSSL(ctx) + } catch { + redisFree(ctx) + self.context = nil + throw error } + } - if database != 0 { - do { - let reply = try executeCommandSync(["SELECT", String(database)]) - if case .error(let msg) = reply { - redisFree(ctx) - self.context = nil - continuation.resume( - throwing: RedisPluginError(code: 2, message: "SELECT \(database) failed: \(msg)") - ) - return - } - } catch { + if let password = password, !password.isEmpty { + do { + let reply = try executeCommandSync(["AUTH", password]) + if case .error(let msg) = reply { redisFree(ctx) self.context = nil - continuation.resume(throwing: error) - return + throw RedisPluginError(code: 1, message: "AUTH failed: \(msg)") } + } catch { + redisFree(ctx) + self.context = nil + throw error } + } + if database != 0 { do { - let reply = try executeCommandSync(["PING"]) + let reply = try executeCommandSync(["SELECT", String(database)]) if case .error(let msg) = reply { redisFree(ctx) self.context = nil - continuation.resume(throwing: RedisPluginError(code: 3, message: "PING failed: \(msg)")) - return + throw RedisPluginError(code: 2, message: "SELECT \(database) failed: \(msg)") } } catch { redisFree(ctx) self.context = nil - continuation.resume(throwing: error) - return + throw error + } + } + + do { + let reply = try executeCommandSync(["PING"]) + if case .error(let msg) = reply { + redisFree(ctx) + self.context = nil + throw RedisPluginError(code: 3, message: "PING failed: \(msg)") } + } catch { + redisFree(ctx) + self.context = nil + throw error + } - let versionString = fetchServerVersionSync() + let versionString = fetchServerVersionSync() - stateLock.lock() - _cachedServerVersion = versionString - _isConnected = true - _currentDatabase = database - stateLock.unlock() + stateLock.lock() + _cachedServerVersion = versionString + _isConnected = true + _currentDatabase = database + stateLock.unlock() - logger.info("Connected to Redis \(versionString ?? "unknown")") - continuation.resume() - } + logger.info("Connected to Redis \(versionString ?? "unknown")") } #else throw RedisPluginError.hiredisUnavailable @@ -362,21 +346,14 @@ final class RedisPluginConnection: @unchecked Sendable { func executeCommand(_ args: [String]) async throws -> RedisReply { #if canImport(CRedis) resetCancellation() - return try await withCheckedThrowingContinuation { [self] (cont: CheckedContinuation) in - queue.async { [self] in - guard !isShuttingDown, context != nil else { - cont.resume(throwing: RedisPluginError.notConnected) - return - } - do { - try checkCancelled() - let result = try executeCommandSync(args) - try checkCancelled() - cont.resume(returning: result) - } catch { - cont.resume(throwing: error) - } + return try await pluginDispatchAsync(on: queue) { [self] in + guard !isShuttingDown, context != nil else { + throw RedisPluginError.notConnected } + try checkCancelled() + let result = try executeCommandSync(args) + try checkCancelled() + return result } #else throw RedisPluginError.hiredisUnavailable @@ -386,21 +363,14 @@ final class RedisPluginConnection: @unchecked Sendable { func executePipeline(_ commands: [[String]]) async throws -> [RedisReply] { #if canImport(CRedis) resetCancellation() - return try await withCheckedThrowingContinuation { [self] (cont: CheckedContinuation<[RedisReply], Error>) in - queue.async { [self] in - guard !isShuttingDown, context != nil else { - cont.resume(throwing: RedisPluginError.notConnected) - return - } - do { - try checkCancelled() - let results = try executePipelineSync(commands) - try checkCancelled() - cont.resume(returning: results) - } catch { - cont.resume(throwing: error) - } + return try await pluginDispatchAsync(on: queue) { [self] in + guard !isShuttingDown, context != nil else { + throw RedisPluginError.notConnected } + try checkCancelled() + let results = try executePipelineSync(commands) + try checkCancelled() + return results } #else throw RedisPluginError.hiredisUnavailable @@ -412,29 +382,18 @@ final class RedisPluginConnection: @unchecked Sendable { func selectDatabase(_ index: Int) async throws { #if canImport(CRedis) resetCancellation() - try await withCheckedThrowingContinuation { [self] (continuation: CheckedContinuation) in - queue.async { [self] in - guard !isShuttingDown, context != nil else { - continuation.resume(throwing: RedisPluginError.notConnected) - return - } - do { - try checkCancelled() - let reply = try executeCommandSync(["SELECT", String(index)]) - if case .error(let msg) = reply { - continuation.resume( - throwing: RedisPluginError(code: 2, message: "SELECT \(index) failed: \(msg)") - ) - return - } - stateLock.lock() - _currentDatabase = index - stateLock.unlock() - continuation.resume() - } catch { - continuation.resume(throwing: error) - } + try await pluginDispatchAsync(on: queue) { [self] in + guard !isShuttingDown, context != nil else { + throw RedisPluginError.notConnected } + try checkCancelled() + let reply = try executeCommandSync(["SELECT", String(index)]) + if case .error(let msg) = reply { + throw RedisPluginError(code: 2, message: "SELECT \(index) failed: \(msg)") + } + stateLock.lock() + _currentDatabase = index + stateLock.unlock() } #else throw RedisPluginError.hiredisUnavailable diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift index fc005f32..cd12c1fb 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift @@ -534,7 +534,10 @@ private extension RedisPluginDriver { return buildEmptyKeyResult(startTime: startTime) } let capped = Array(keys.prefix(PluginRowLimits.defaultMax)) - return try await buildKeyBrowseResult(keys: capped, connection: conn, startTime: startTime) + let keysTruncated = keys.count > PluginRowLimits.defaultMax + return try await buildKeyBrowseResult( + keys: capped, connection: conn, startTime: startTime, isTruncated: keysTruncated + ) case .scan(let cursor, let pattern, let count): var args = ["SCAN", String(cursor)] @@ -1005,7 +1008,10 @@ private extension RedisPluginDriver { } let capped = Array(keys.prefix(PluginRowLimits.defaultMax)) - return try await buildKeyBrowseResult(keys: capped, connection: conn, startTime: startTime) + let keysTruncated = keys.count > PluginRowLimits.defaultMax + return try await buildKeyBrowseResult( + keys: capped, connection: conn, startTime: startTime, isTruncated: keysTruncated + ) } } @@ -1018,7 +1024,8 @@ private extension RedisPluginDriver { func buildKeyBrowseResult( keys: [String], connection conn: RedisPluginConnection, - startTime: Date + startTime: Date, + isTruncated: Bool = false ) async throws -> PluginQueryResult { guard !keys.isEmpty else { return buildEmptyKeyResult(startTime: startTime) @@ -1047,7 +1054,8 @@ private extension RedisPluginDriver { columnTypeNames: ["String", "RedisType", "RedisInt", "RedisRaw"], rows: rows, rowsAffected: 0, - executionTime: Date().timeIntervalSince(startTime) + executionTime: Date().timeIntervalSince(startTime), + isTruncated: isTruncated ) } diff --git a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift index 3bfd0afd..adc0086b 100644 --- a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift +++ b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift @@ -96,9 +96,11 @@ private actor SQLiteConnectionActor { var rows: [[String?]] = [] var rowsAffected = 0 + var truncated = false while sqlite3_step(statement) == SQLITE_ROW { if rows.count >= PluginRowLimits.defaultMax { + truncated = true break } @@ -128,7 +130,8 @@ private actor SQLiteConnectionActor { columnTypeNames: columnTypeNames, rows: rows, rowsAffected: rowsAffected, - executionTime: executionTime + executionTime: executionTime, + isTruncated: truncated ) } @@ -193,9 +196,11 @@ private actor SQLiteConnectionActor { var rows: [[String?]] = [] var rowsAffected = 0 + var truncated = false while sqlite3_step(statement) == SQLITE_ROW { if rows.count >= PluginRowLimits.defaultMax { + truncated = true break } @@ -225,7 +230,8 @@ private actor SQLiteConnectionActor { columnTypeNames: columnTypeNames, rows: rows, rowsAffected: rowsAffected, - executionTime: executionTime + executionTime: executionTime, + isTruncated: truncated ) } } @@ -236,6 +242,7 @@ private struct SQLiteRawResult: Sendable { let rows: [[String?]] let rowsAffected: Int let executionTime: TimeInterval + let isTruncated: Bool } // MARK: - SQLite Plugin Driver @@ -300,7 +307,8 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { columnTypeNames: rawResult.columnTypeNames, rows: rawResult.rows, rowsAffected: rawResult.rowsAffected, - executionTime: rawResult.executionTime + executionTime: rawResult.executionTime, + isTruncated: rawResult.isTruncated ) } @@ -311,7 +319,8 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { columnTypeNames: rawResult.columnTypeNames, rows: rawResult.rows, rowsAffected: rawResult.rowsAffected, - executionTime: rawResult.executionTime + executionTime: rawResult.executionTime, + isTruncated: rawResult.isTruncated ) } @@ -656,20 +665,11 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { // MARK: - Errors -enum SQLitePluginError: LocalizedError { +enum SQLitePluginError: Error { case connectionFailed(String) case notConnected case queryFailed(String) case unsupportedOperation - - var errorDescription: String? { - switch self { - case .connectionFailed(let message): return "Connection failed: \(message)" - case .notConnected: return "Not connected to database" - case .queryFailed(let message): return "Query failed: \(message)" - case .unsupportedOperation: return "Operation not supported" - } - } } extension SQLitePluginError: PluginDriverError { diff --git a/Plugins/TableProPluginKit/PluginConcurrencySupport.swift b/Plugins/TableProPluginKit/PluginConcurrencySupport.swift index 1e6f7410..cbba00ce 100644 --- a/Plugins/TableProPluginKit/PluginConcurrencySupport.swift +++ b/Plugins/TableProPluginKit/PluginConcurrencySupport.swift @@ -31,3 +31,27 @@ public func pluginDispatchAsync( } } } + +public func pluginDispatchAsync( + on queue: DispatchQueue, + cancellationCheck: (@Sendable () -> Bool)? = nil, + execute work: @escaping @Sendable () throws -> T +) async throws -> T { + try Task.checkCancellation() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + queue.async { + if let check = cancellationCheck, check() { + continuation.resume(throwing: CancellationError()) + return + } + do { + let result = try work() + continuation.resume(returning: result) + } catch { + continuation.resume(throwing: error) + } + } + } + } onCancel: {} +} diff --git a/Plugins/TableProPluginKit/PluginDriverError.swift b/Plugins/TableProPluginKit/PluginDriverError.swift index 01a2a99b..36391850 100644 --- a/Plugins/TableProPluginKit/PluginDriverError.swift +++ b/Plugins/TableProPluginKit/PluginDriverError.swift @@ -16,4 +16,18 @@ public extension PluginDriverError { var pluginErrorCode: Int? { nil } var pluginSqlState: String? { nil } var pluginErrorDetail: String? { nil } + + var errorDescription: String? { + var desc = pluginErrorMessage + if let code = pluginErrorCode { + desc = "[\(code)] \(desc)" + } + if let state = pluginSqlState { + desc += " (SQLSTATE: \(state))" + } + if let detail = pluginErrorDetail, !detail.isEmpty { + desc += "\n\(detail)" + } + return desc + } } diff --git a/Plugins/TableProPluginKit/PluginQueryResult.swift b/Plugins/TableProPluginKit/PluginQueryResult.swift index f5a567bb..9da11e8f 100644 --- a/Plugins/TableProPluginKit/PluginQueryResult.swift +++ b/Plugins/TableProPluginKit/PluginQueryResult.swift @@ -6,19 +6,22 @@ public struct PluginQueryResult: Codable, Sendable { public let rows: [[String?]] public let rowsAffected: Int public let executionTime: TimeInterval + public let isTruncated: Bool public init( columns: [String], columnTypeNames: [String], rows: [[String?]], rowsAffected: Int, - executionTime: TimeInterval + executionTime: TimeInterval, + isTruncated: Bool = false ) { self.columns = columns self.columnTypeNames = columnTypeNames self.rows = rows self.rowsAffected = rowsAffected self.executionTime = executionTime + self.isTruncated = isTruncated } public static let empty = PluginQueryResult( diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index b7745f05..31ef4ade 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -42,18 +42,7 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { try await pluginDriver.connect() status = .connected } catch { - if let driverError = error as? any PluginDriverError { - var message = driverError.pluginErrorMessage - if let code = driverError.pluginErrorCode { - message = "[\(code)] \(message)" - } - if let state = driverError.pluginSqlState { - message += " (SQLSTATE: \(state))" - } - status = .error(message) - } else { - status = .error(error.localizedDescription) - } + status = .error(error.localizedDescription) throw error } } @@ -288,7 +277,7 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { private func mapQueryResult(_ pluginResult: PluginQueryResult) -> QueryResult { let columnTypes = pluginResult.columnTypeNames.map { mapColumnType(rawTypeName: $0) } - return QueryResult( + var result = QueryResult( columns: pluginResult.columns, columnTypes: columnTypes, rows: pluginResult.rows, @@ -296,6 +285,8 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { executionTime: pluginResult.executionTime, error: nil ) + result.isTruncated = pluginResult.isTruncated + return result } private func mapColumnType(rawTypeName: String) -> ColumnType {