From 8e97f1b710901045d8f1af8f3985fa9a9c50c54b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 31 Mar 2026 22:11:14 +0700 Subject: [PATCH 01/61] feat: add TableProCore cross-platform Swift Package --- Packages/TableProCore/Package.swift | 54 ++ .../TableProDatabase/ConnectionError.swift | 18 + .../TableProDatabase/ConnectionManager.swift | 120 +++ .../TableProDatabase/ConnectionSession.swift | 27 + .../TableProDatabase/DatabaseDriver.swift | 29 + .../PluginDriverAdapter.swift | 102 +++ .../PluginMetadataProvider.swift | 64 ++ .../Protocols/PluginLoader.swift | 7 + .../Protocols/SSHProvider.swift | 22 + .../Protocols/SecureStore.swift | 7 + .../TableProModels/ConnectionGroup.swift | 17 + .../TableProModels/ConnectionTag.swift | 17 + .../TableProModels/DatabaseConnection.swift | 62 ++ .../Sources/TableProModels/DatabaseType.swift | 44 + .../Sources/TableProModels/QueryResult.swift | 246 ++++++ .../TableProModels/SSHConfiguration.swift | 51 ++ .../TableProModels/SSLConfiguration.swift | 27 + .../Sources/TableProModels/SchemaTypes.swift | 95 +++ .../Sources/TableProModels/SortState.swift | 62 ++ .../Sources/TableProModels/TableFilter.swift | 95 +++ .../TableProPluginKit/CompletionEntry.swift | 32 + .../TableProPluginKit/ConnectionField.swift | 136 +++ .../TableProPluginKit/ConnectionMode.swift | 7 + .../DriverConnectionConfig.swift | 26 + .../TableProPluginKit/DriverPlugin.swift | 116 +++ .../TableProPluginKit/EditorLanguage.swift | 45 + .../TableProPluginKit/ExplainVariant.swift | 13 + .../ExportFormatPlugin.swift | 226 +++++ .../TableProPluginKit/GroupingStrategy.swift | 7 + .../ImportFormatPlugin.swift | 172 ++++ .../TableProPluginKit/NavigationModel.swift | 6 + .../TableProPluginKit/PathFieldRole.swift | 8 + .../TableProPluginKit/PluginCapability.swift | 7 + .../TableProPluginKit/PluginColumnInfo.swift | 35 + .../PluginConcurrencySupport.swift | 57 ++ .../PluginDatabaseDriver.swift | 533 ++++++++++++ .../PluginDatabaseMetadata.swift | 20 + .../TableProPluginKit/PluginDriverError.swift | 28 + .../PluginForeignKeyInfo.swift | 26 + .../TableProPluginKit/PluginIndexInfo.swift | 23 + .../TableProPluginKit/PluginQueryResult.swift | 37 + .../TableProPluginKit/PluginRowLimits.swift | 5 + .../PluginSettingsStorage.swift | 34 + .../TableProPluginKit/PluginTableInfo.swift | 13 + .../PluginTableMetadata.swift | 29 + .../TableProPluginKit/PostConnectAction.swift | 6 + .../SQLDialectDescriptor.swift | 77 ++ .../TableProPluginKit/SchemaTypes.swift | 113 +++ .../TableProPluginKit/SettablePlugin.swift | 9 + .../StructureColumnField.swift | 23 + .../TableProPluginKit/TableProPlugin.swift | 15 + .../TableProQuery/FilterSQLGenerator.swift | 121 +++ .../Sources/TableProQuery/RowParser.swift | 131 +++ .../TableProQuery/SQLDialectProvider.swift | 44 + .../TableProQuery/SQLStatementGenerator.swift | 66 ++ .../TableProQuery/TableQueryBuilder.swift | 119 +++ .../ConnectionManagerTests.swift | 161 ++++ .../PluginDriverAdapterTests.swift | 120 +++ .../DatabaseTypeTests.swift | 63 ++ .../QueryResultMappingTests.swift | 101 +++ .../TableFilterTests.swift | 92 +++ .../FilterSQLGeneratorTests.swift | 126 +++ .../TableQueryBuilderTests.swift | 85 ++ docs/development/tablepro-core-design.md | 771 ++++++++++++++++++ 64 files changed, 5050 insertions(+) create mode 100644 Packages/TableProCore/Package.swift create mode 100644 Packages/TableProCore/Sources/TableProDatabase/ConnectionError.swift create mode 100644 Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift create mode 100644 Packages/TableProCore/Sources/TableProDatabase/ConnectionSession.swift create mode 100644 Packages/TableProCore/Sources/TableProDatabase/DatabaseDriver.swift create mode 100644 Packages/TableProCore/Sources/TableProDatabase/PluginDriverAdapter.swift create mode 100644 Packages/TableProCore/Sources/TableProDatabase/PluginMetadataProvider.swift create mode 100644 Packages/TableProCore/Sources/TableProDatabase/Protocols/PluginLoader.swift create mode 100644 Packages/TableProCore/Sources/TableProDatabase/Protocols/SSHProvider.swift create mode 100644 Packages/TableProCore/Sources/TableProDatabase/Protocols/SecureStore.swift create mode 100644 Packages/TableProCore/Sources/TableProModels/ConnectionGroup.swift create mode 100644 Packages/TableProCore/Sources/TableProModels/ConnectionTag.swift create mode 100644 Packages/TableProCore/Sources/TableProModels/DatabaseConnection.swift create mode 100644 Packages/TableProCore/Sources/TableProModels/DatabaseType.swift create mode 100644 Packages/TableProCore/Sources/TableProModels/QueryResult.swift create mode 100644 Packages/TableProCore/Sources/TableProModels/SSHConfiguration.swift create mode 100644 Packages/TableProCore/Sources/TableProModels/SSLConfiguration.swift create mode 100644 Packages/TableProCore/Sources/TableProModels/SchemaTypes.swift create mode 100644 Packages/TableProCore/Sources/TableProModels/SortState.swift create mode 100644 Packages/TableProCore/Sources/TableProModels/TableFilter.swift create mode 100644 Packages/TableProCore/Sources/TableProPluginKit/CompletionEntry.swift create mode 100644 Packages/TableProCore/Sources/TableProPluginKit/ConnectionField.swift create mode 100644 Packages/TableProCore/Sources/TableProPluginKit/ConnectionMode.swift create mode 100644 Packages/TableProCore/Sources/TableProPluginKit/DriverConnectionConfig.swift create mode 100644 Packages/TableProCore/Sources/TableProPluginKit/DriverPlugin.swift create mode 100644 Packages/TableProCore/Sources/TableProPluginKit/EditorLanguage.swift create mode 100644 Packages/TableProCore/Sources/TableProPluginKit/ExplainVariant.swift create mode 100644 Packages/TableProCore/Sources/TableProPluginKit/ExportFormatPlugin.swift create mode 100644 Packages/TableProCore/Sources/TableProPluginKit/GroupingStrategy.swift create mode 100644 Packages/TableProCore/Sources/TableProPluginKit/ImportFormatPlugin.swift create mode 100644 Packages/TableProCore/Sources/TableProPluginKit/NavigationModel.swift create mode 100644 Packages/TableProCore/Sources/TableProPluginKit/PathFieldRole.swift create mode 100644 Packages/TableProCore/Sources/TableProPluginKit/PluginCapability.swift create mode 100644 Packages/TableProCore/Sources/TableProPluginKit/PluginColumnInfo.swift create mode 100644 Packages/TableProCore/Sources/TableProPluginKit/PluginConcurrencySupport.swift create mode 100644 Packages/TableProCore/Sources/TableProPluginKit/PluginDatabaseDriver.swift create mode 100644 Packages/TableProCore/Sources/TableProPluginKit/PluginDatabaseMetadata.swift create mode 100644 Packages/TableProCore/Sources/TableProPluginKit/PluginDriverError.swift create mode 100644 Packages/TableProCore/Sources/TableProPluginKit/PluginForeignKeyInfo.swift create mode 100644 Packages/TableProCore/Sources/TableProPluginKit/PluginIndexInfo.swift create mode 100644 Packages/TableProCore/Sources/TableProPluginKit/PluginQueryResult.swift create mode 100644 Packages/TableProCore/Sources/TableProPluginKit/PluginRowLimits.swift create mode 100644 Packages/TableProCore/Sources/TableProPluginKit/PluginSettingsStorage.swift create mode 100644 Packages/TableProCore/Sources/TableProPluginKit/PluginTableInfo.swift create mode 100644 Packages/TableProCore/Sources/TableProPluginKit/PluginTableMetadata.swift create mode 100644 Packages/TableProCore/Sources/TableProPluginKit/PostConnectAction.swift create mode 100644 Packages/TableProCore/Sources/TableProPluginKit/SQLDialectDescriptor.swift create mode 100644 Packages/TableProCore/Sources/TableProPluginKit/SchemaTypes.swift create mode 100644 Packages/TableProCore/Sources/TableProPluginKit/SettablePlugin.swift create mode 100644 Packages/TableProCore/Sources/TableProPluginKit/StructureColumnField.swift create mode 100644 Packages/TableProCore/Sources/TableProPluginKit/TableProPlugin.swift create mode 100644 Packages/TableProCore/Sources/TableProQuery/FilterSQLGenerator.swift create mode 100644 Packages/TableProCore/Sources/TableProQuery/RowParser.swift create mode 100644 Packages/TableProCore/Sources/TableProQuery/SQLDialectProvider.swift create mode 100644 Packages/TableProCore/Sources/TableProQuery/SQLStatementGenerator.swift create mode 100644 Packages/TableProCore/Sources/TableProQuery/TableQueryBuilder.swift create mode 100644 Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTests.swift create mode 100644 Packages/TableProCore/Tests/TableProDatabaseTests/PluginDriverAdapterTests.swift create mode 100644 Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift create mode 100644 Packages/TableProCore/Tests/TableProModelsTests/QueryResultMappingTests.swift create mode 100644 Packages/TableProCore/Tests/TableProModelsTests/TableFilterTests.swift create mode 100644 Packages/TableProCore/Tests/TableProQueryTests/FilterSQLGeneratorTests.swift create mode 100644 Packages/TableProCore/Tests/TableProQueryTests/TableQueryBuilderTests.swift create mode 100644 docs/development/tablepro-core-design.md diff --git a/Packages/TableProCore/Package.swift b/Packages/TableProCore/Package.swift new file mode 100644 index 000000000..e894dd359 --- /dev/null +++ b/Packages/TableProCore/Package.swift @@ -0,0 +1,54 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "TableProCore", + platforms: [ + .macOS(.v14), + .iOS(.v17) + ], + products: [ + .library(name: "TableProPluginKit", targets: ["TableProPluginKit"]), + .library(name: "TableProModels", targets: ["TableProModels"]), + .library(name: "TableProDatabase", targets: ["TableProDatabase"]), + .library(name: "TableProQuery", targets: ["TableProQuery"]) + ], + targets: [ + .target( + name: "TableProPluginKit", + dependencies: [], + path: "Sources/TableProPluginKit" + ), + .target( + name: "TableProModels", + dependencies: ["TableProPluginKit"], + path: "Sources/TableProModels" + ), + .target( + name: "TableProDatabase", + dependencies: ["TableProModels", "TableProPluginKit"], + path: "Sources/TableProDatabase" + ), + .target( + name: "TableProQuery", + dependencies: ["TableProModels", "TableProPluginKit"], + path: "Sources/TableProQuery" + ), + .testTarget( + name: "TableProModelsTests", + dependencies: ["TableProModels", "TableProPluginKit"], + path: "Tests/TableProModelsTests" + ), + .testTarget( + name: "TableProDatabaseTests", + dependencies: ["TableProDatabase", "TableProModels", "TableProPluginKit"], + path: "Tests/TableProDatabaseTests" + ), + .testTarget( + name: "TableProQueryTests", + dependencies: ["TableProQuery", "TableProModels", "TableProPluginKit"], + path: "Tests/TableProQueryTests" + ) + ] +) diff --git a/Packages/TableProCore/Sources/TableProDatabase/ConnectionError.swift b/Packages/TableProCore/Sources/TableProDatabase/ConnectionError.swift new file mode 100644 index 000000000..ffb50f96a --- /dev/null +++ b/Packages/TableProCore/Sources/TableProDatabase/ConnectionError.swift @@ -0,0 +1,18 @@ +import Foundation + +public enum ConnectionError: Error, LocalizedError { + case pluginNotFound(String) + case notConnected + case sshNotSupported + + public var errorDescription: String? { + switch self { + case .pluginNotFound(let type): + return "No driver plugin for database type: \(type)" + case .notConnected: + return "Not connected to database" + case .sshNotSupported: + return "SSH tunneling is not available on this platform" + } + } +} diff --git a/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift b/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift new file mode 100644 index 000000000..08a32a2e4 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift @@ -0,0 +1,120 @@ +import Foundation +import TableProModels +import TableProPluginKit + +public final class ConnectionManager: @unchecked Sendable { + private let pluginLoader: PluginLoader + private let secureStore: SecureStore + private let sshProvider: SSHProvider? + + private let lock = NSLock() + private var sessions: [UUID: ConnectionSession] = [:] + + public init( + pluginLoader: PluginLoader, + secureStore: SecureStore, + sshProvider: SSHProvider? = nil + ) { + self.pluginLoader = pluginLoader + self.secureStore = secureStore + self.sshProvider = sshProvider + } + + public func connect(_ connection: DatabaseConnection) async throws -> ConnectionSession { + let password = try secureStore.retrieve(forKey: connection.id.uuidString) + + var effectiveHost = connection.host + var effectivePort = connection.port + if connection.sshEnabled, let ssh = connection.sshConfiguration { + guard let provider = sshProvider else { + throw ConnectionError.sshNotSupported + } + let tunnel = try await provider.createTunnel( + config: ssh, + remoteHost: connection.host, + remotePort: connection.port + ) + effectiveHost = tunnel.localHost + effectivePort = tunnel.localPort + } + + do { + guard let plugin = pluginLoader.driverPlugin(for: connection.type.pluginTypeId) else { + throw ConnectionError.pluginNotFound(connection.type.rawValue) + } + + let config = DriverConnectionConfig( + host: effectiveHost, + port: effectivePort, + username: connection.username, + password: password ?? "", + database: connection.database, + additionalFields: connection.additionalFields + ) + let pluginDriver = plugin.createDriver(config: config) + + let driver = PluginDriverAdapter(pluginDriver: pluginDriver) + try await driver.connect() + + let session = ConnectionSession( + connectionId: connection.id, + driver: driver, + activeDatabase: connection.database, + status: .connected + ) + storeSession(session, for: connection.id) + return session + } catch { + if connection.sshEnabled, let provider = sshProvider { + try? await provider.closeTunnel(for: connection.id) + } + throw error + } + } + + public func disconnect(_ connectionId: UUID) async { + let session = removeSession(for: connectionId) + + guard let session else { return } + try? await session.driver.disconnect() + + if let sshProvider { + try? await sshProvider.closeTunnel(for: connectionId) + } + } + + public func updateSession(_ connectionId: UUID, _ mutation: (inout ConnectionSession) -> Void) { + lock.lock() + defer { lock.unlock() } + guard var session = sessions[connectionId] else { return } + mutation(&session) + sessions[connectionId] = session + } + + public func switchDatabase(_ connectionId: UUID, to database: String) async throws { + guard let session = session(for: connectionId) else { + throw ConnectionError.notConnected + } + try await session.driver.switchDatabase(to: database) + updateSession(connectionId) { $0.activeDatabase = database } + } + + private func storeSession(_ session: ConnectionSession, for id: UUID) { + lock.lock() + sessions[id] = session + lock.unlock() + } + + private func removeSession(for id: UUID) -> ConnectionSession? { + lock.lock() + let session = sessions.removeValue(forKey: id) + lock.unlock() + return session + } + + public func session(for connectionId: UUID) -> ConnectionSession? { + lock.lock() + defer { lock.unlock() } + return sessions[connectionId] + } +} diff --git a/Packages/TableProCore/Sources/TableProDatabase/ConnectionSession.swift b/Packages/TableProCore/Sources/TableProDatabase/ConnectionSession.swift new file mode 100644 index 000000000..ef06ae73e --- /dev/null +++ b/Packages/TableProCore/Sources/TableProDatabase/ConnectionSession.swift @@ -0,0 +1,27 @@ +import Foundation +import TableProModels + +public struct ConnectionSession: Sendable { + public let connectionId: UUID + public let driver: any DatabaseDriver + public internal(set) var activeDatabase: String + public internal(set) var currentSchema: String? + public internal(set) var status: ConnectionStatus + public internal(set) var tables: [TableInfo] + + public init( + connectionId: UUID, + driver: any DatabaseDriver, + activeDatabase: String, + currentSchema: String? = nil, + status: ConnectionStatus = .connected, + tables: [TableInfo] = [] + ) { + self.connectionId = connectionId + self.driver = driver + self.activeDatabase = activeDatabase + self.currentSchema = currentSchema + self.status = status + self.tables = tables + } +} diff --git a/Packages/TableProCore/Sources/TableProDatabase/DatabaseDriver.swift b/Packages/TableProCore/Sources/TableProDatabase/DatabaseDriver.swift new file mode 100644 index 000000000..51e8efce7 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProDatabase/DatabaseDriver.swift @@ -0,0 +1,29 @@ +import Foundation +import TableProModels + +public protocol DatabaseDriver: AnyObject, Sendable { + func connect() async throws + func disconnect() async throws + func ping() async throws -> Bool + + func execute(query: String) async throws -> QueryResult + func cancelCurrentQuery() async throws + + func fetchTables(schema: String?) async throws -> [TableInfo] + func fetchColumns(table: String, schema: String?) async throws -> [ColumnInfo] + func fetchIndexes(table: String, schema: String?) async throws -> [IndexInfo] + func fetchForeignKeys(table: String, schema: String?) async throws -> [ForeignKeyInfo] + func fetchDatabases() async throws -> [String] + + func switchDatabase(to name: String) async throws + var supportsSchemas: Bool { get } + func switchSchema(to name: String) async throws + var currentSchema: String? { get } + + var supportsTransactions: Bool { get } + func beginTransaction() async throws + func commitTransaction() async throws + func rollbackTransaction() async throws + + var serverVersion: String? { get } +} diff --git a/Packages/TableProCore/Sources/TableProDatabase/PluginDriverAdapter.swift b/Packages/TableProCore/Sources/TableProDatabase/PluginDriverAdapter.swift new file mode 100644 index 000000000..3e6903ba8 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProDatabase/PluginDriverAdapter.swift @@ -0,0 +1,102 @@ +import Foundation +import TableProModels +import TableProPluginKit + +public final class PluginDriverAdapter: DatabaseDriver, @unchecked Sendable { + private let pluginDriver: any PluginDatabaseDriver + + public init(pluginDriver: any PluginDatabaseDriver) { + self.pluginDriver = pluginDriver + } + + public func connect() async throws { + try await pluginDriver.connect() + } + + // PluginDatabaseDriver.disconnect() is sync and non-throwing, while + // DatabaseDriver.disconnect() is async throws. A non-throwing call + // satisfies the throwing requirement, so no try is needed here. + public func disconnect() async throws { + pluginDriver.disconnect() + } + + public func ping() async throws -> Bool { + do { + try await pluginDriver.ping() + return true + } catch { + return false + } + } + + public func execute(query: String) async throws -> QueryResult { + let pluginResult = try await pluginDriver.execute(query: query) + return QueryResult(from: pluginResult) + } + + public func cancelCurrentQuery() async throws { + try pluginDriver.cancelQuery() + } + + public func fetchTables(schema: String?) async throws -> [TableInfo] { + let pluginTables = try await pluginDriver.fetchTables(schema: schema) + return pluginTables.map { TableInfo(from: $0) } + } + + public func fetchColumns(table: String, schema: String?) async throws -> [ColumnInfo] { + let pluginColumns = try await pluginDriver.fetchColumns(table: table, schema: schema) + return pluginColumns.enumerated().map { index, col in + ColumnInfo(from: col, ordinalPosition: index) + } + } + + public func fetchIndexes(table: String, schema: String?) async throws -> [IndexInfo] { + let pluginIndexes = try await pluginDriver.fetchIndexes(table: table, schema: schema) + return pluginIndexes.map { IndexInfo(from: $0) } + } + + public func fetchForeignKeys(table: String, schema: String?) async throws -> [ForeignKeyInfo] { + let pluginFKs = try await pluginDriver.fetchForeignKeys(table: table, schema: schema) + return pluginFKs.map { ForeignKeyInfo(from: $0) } + } + + public func fetchDatabases() async throws -> [String] { + try await pluginDriver.fetchDatabases() + } + + public func switchDatabase(to name: String) async throws { + try await pluginDriver.switchDatabase(to: name) + } + + public var supportsSchemas: Bool { + pluginDriver.supportsSchemas + } + + public func switchSchema(to name: String) async throws { + try await pluginDriver.switchSchema(to: name) + } + + public var currentSchema: String? { + pluginDriver.currentSchema + } + + public var supportsTransactions: Bool { + pluginDriver.supportsTransactions + } + + public func beginTransaction() async throws { + try await pluginDriver.beginTransaction() + } + + public func commitTransaction() async throws { + try await pluginDriver.commitTransaction() + } + + public func rollbackTransaction() async throws { + try await pluginDriver.rollbackTransaction() + } + + public var serverVersion: String? { + pluginDriver.serverVersion + } +} diff --git a/Packages/TableProCore/Sources/TableProDatabase/PluginMetadataProvider.swift b/Packages/TableProCore/Sources/TableProDatabase/PluginMetadataProvider.swift new file mode 100644 index 000000000..1817e5cc4 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProDatabase/PluginMetadataProvider.swift @@ -0,0 +1,64 @@ +import Foundation +import TableProModels +import TableProPluginKit + +public final class PluginMetadataProvider: Sendable { + private let pluginLoader: PluginLoader + + public init(pluginLoader: PluginLoader) { + self.pluginLoader = pluginLoader + } + + public func displayName(for type: DatabaseType) -> String { + plugin(for: type)?.databaseDisplayName ?? type.rawValue.capitalized + } + + public func defaultPort(for type: DatabaseType) -> Int { + plugin(for: type)?.defaultPort ?? 3306 + } + + public func iconName(for type: DatabaseType) -> String { + plugin(for: type)?.iconName ?? "server.rack" + } + + public func supportsSSH(for type: DatabaseType) -> Bool { + plugin(for: type)?.supportsSSH ?? false + } + + public func supportsSSL(for type: DatabaseType) -> Bool { + plugin(for: type)?.supportsSSL ?? false + } + + public func sqlDialect(for type: DatabaseType) -> SQLDialectDescriptor? { + plugin(for: type)?.sqlDialect + } + + public func brandColorHex(for type: DatabaseType) -> String { + plugin(for: type)?.brandColorHex ?? "#808080" + } + + public func editorLanguage(for type: DatabaseType) -> EditorLanguage { + plugin(for: type)?.editorLanguage ?? .sql + } + + public func connectionMode(for type: DatabaseType) -> ConnectionMode { + plugin(for: type)?.connectionMode ?? .network + } + + public func supportsDatabaseSwitching(for type: DatabaseType) -> Bool { + plugin(for: type)?.supportsDatabaseSwitching ?? true + } + + public func supportsSchemaSwitching(for type: DatabaseType) -> Bool { + plugin(for: type)?.supportsSchemaSwitching ?? false + } + + public func groupingStrategy(for type: DatabaseType) -> GroupingStrategy { + plugin(for: type)?.databaseGroupingStrategy ?? .byDatabase + } + + private func plugin(for type: DatabaseType) -> (any DriverPlugin.Type)? { + guard let plugin = pluginLoader.driverPlugin(for: type.pluginTypeId) else { return nil } + return Swift.type(of: plugin) + } +} diff --git a/Packages/TableProCore/Sources/TableProDatabase/Protocols/PluginLoader.swift b/Packages/TableProCore/Sources/TableProDatabase/Protocols/PluginLoader.swift new file mode 100644 index 000000000..033036bd1 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProDatabase/Protocols/PluginLoader.swift @@ -0,0 +1,7 @@ +import Foundation +import TableProPluginKit + +public protocol PluginLoader: Sendable { + func availablePlugins() -> [any DriverPlugin] + func driverPlugin(for typeId: String) -> (any DriverPlugin)? +} diff --git a/Packages/TableProCore/Sources/TableProDatabase/Protocols/SSHProvider.swift b/Packages/TableProCore/Sources/TableProDatabase/Protocols/SSHProvider.swift new file mode 100644 index 000000000..066c94d10 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProDatabase/Protocols/SSHProvider.swift @@ -0,0 +1,22 @@ +import Foundation +import TableProModels + +public protocol SSHProvider: Sendable { + func createTunnel( + config: SSHConfiguration, + remoteHost: String, + remotePort: Int + ) async throws -> SSHTunnel + + func closeTunnel(for connectionId: UUID) async throws +} + +public struct SSHTunnel: Sendable { + public let localHost: String + public let localPort: Int + + public init(localHost: String, localPort: Int) { + self.localHost = localHost + self.localPort = localPort + } +} diff --git a/Packages/TableProCore/Sources/TableProDatabase/Protocols/SecureStore.swift b/Packages/TableProCore/Sources/TableProDatabase/Protocols/SecureStore.swift new file mode 100644 index 000000000..528dc132f --- /dev/null +++ b/Packages/TableProCore/Sources/TableProDatabase/Protocols/SecureStore.swift @@ -0,0 +1,7 @@ +import Foundation + +public protocol SecureStore: Sendable { + func store(_ value: String, forKey key: String) throws + func retrieve(forKey key: String) throws -> String? + func delete(forKey key: String) throws +} diff --git a/Packages/TableProCore/Sources/TableProModels/ConnectionGroup.swift b/Packages/TableProCore/Sources/TableProModels/ConnectionGroup.swift new file mode 100644 index 000000000..c538405c2 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProModels/ConnectionGroup.swift @@ -0,0 +1,17 @@ +import Foundation + +public struct ConnectionGroup: Identifiable, Codable, Sendable { + public var id: UUID + public var name: String + public var sortOrder: Int + + public init( + id: UUID = UUID(), + name: String = "", + sortOrder: Int = 0 + ) { + self.id = id + self.name = name + self.sortOrder = sortOrder + } +} diff --git a/Packages/TableProCore/Sources/TableProModels/ConnectionTag.swift b/Packages/TableProCore/Sources/TableProModels/ConnectionTag.swift new file mode 100644 index 000000000..a0a347640 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProModels/ConnectionTag.swift @@ -0,0 +1,17 @@ +import Foundation + +public struct ConnectionTag: Identifiable, Codable, Sendable { + public var id: UUID + public var name: String + public var colorHex: String + + public init( + id: UUID = UUID(), + name: String = "", + colorHex: String = "#808080" + ) { + self.id = id + self.name = name + self.colorHex = colorHex + } +} diff --git a/Packages/TableProCore/Sources/TableProModels/DatabaseConnection.swift b/Packages/TableProCore/Sources/TableProModels/DatabaseConnection.swift new file mode 100644 index 000000000..086d01f0f --- /dev/null +++ b/Packages/TableProCore/Sources/TableProModels/DatabaseConnection.swift @@ -0,0 +1,62 @@ +import Foundation + +public struct DatabaseConnection: Identifiable, Codable, Sendable { + public var id: UUID + public var name: String + public var type: DatabaseType + public var host: String + public var port: Int + public var username: String + public var database: String + public var colorTag: String? + public var isReadOnly: Bool + public var queryTimeoutSeconds: Int? + public var additionalFields: [String: String] + + public var sshEnabled: Bool + public var sshConfiguration: SSHConfiguration? + + public var sslEnabled: Bool + public var sslConfiguration: SSLConfiguration? + + public var groupId: UUID? + public var sortOrder: Int + + public init( + id: UUID = UUID(), + name: String = "", + type: DatabaseType = .mysql, + host: String = "127.0.0.1", + port: Int = 3306, + username: String = "", + database: String = "", + colorTag: String? = nil, + isReadOnly: Bool = false, + queryTimeoutSeconds: Int? = nil, + additionalFields: [String: String] = [:], + sshEnabled: Bool = false, + sshConfiguration: SSHConfiguration? = nil, + sslEnabled: Bool = false, + sslConfiguration: SSLConfiguration? = nil, + groupId: UUID? = nil, + sortOrder: Int = 0 + ) { + self.id = id + self.name = name + self.type = type + self.host = host + self.port = port + self.username = username + self.database = database + self.colorTag = colorTag + self.isReadOnly = isReadOnly + self.queryTimeoutSeconds = queryTimeoutSeconds + self.additionalFields = additionalFields + self.sshEnabled = sshEnabled + self.sshConfiguration = sshConfiguration + self.sslEnabled = sslEnabled + self.sslConfiguration = sslConfiguration + self.groupId = groupId + self.sortOrder = sortOrder + } +} diff --git a/Packages/TableProCore/Sources/TableProModels/DatabaseType.swift b/Packages/TableProCore/Sources/TableProModels/DatabaseType.swift new file mode 100644 index 000000000..a93b279f7 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProModels/DatabaseType.swift @@ -0,0 +1,44 @@ +import Foundation + +public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + // MARK: - Known Constants + + public static let mysql = DatabaseType(rawValue: "mysql") + public static let mariadb = DatabaseType(rawValue: "mariadb") + public static let postgresql = DatabaseType(rawValue: "postgresql") + public static let sqlite = DatabaseType(rawValue: "sqlite") + public static let redis = DatabaseType(rawValue: "redis") + public static let mongodb = DatabaseType(rawValue: "mongodb") + public static let clickhouse = DatabaseType(rawValue: "clickhouse") + public static let mssql = DatabaseType(rawValue: "mssql") + public static let oracle = DatabaseType(rawValue: "oracle") + public static let duckdb = DatabaseType(rawValue: "duckdb") + public static let cassandra = DatabaseType(rawValue: "cassandra") + public static let redshift = DatabaseType(rawValue: "redshift") + public static let etcd = DatabaseType(rawValue: "etcd") + public static let cloudflareD1 = DatabaseType(rawValue: "cloudflared1") + public static let dynamodb = DatabaseType(rawValue: "dynamodb") + public static let bigquery = DatabaseType(rawValue: "bigquery") + + public static let allKnownTypes: [DatabaseType] = [ + .mysql, .mariadb, .postgresql, .sqlite, .redis, .mongodb, + .clickhouse, .mssql, .oracle, .duckdb, .cassandra, .redshift, + .etcd, .cloudflareD1, .dynamodb, .bigquery + ] + + /// Plugin type ID for plugin lookup. + /// Multi-type plugins share a single driver: mariadb -> "mysql", redshift -> "postgresql" + public var pluginTypeId: String { + switch self { + case .mariadb: return DatabaseType.mysql.rawValue + case .redshift: return DatabaseType.postgresql.rawValue + default: return rawValue + } + } +} diff --git a/Packages/TableProCore/Sources/TableProModels/QueryResult.swift b/Packages/TableProCore/Sources/TableProModels/QueryResult.swift new file mode 100644 index 000000000..f46610704 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProModels/QueryResult.swift @@ -0,0 +1,246 @@ +import Foundation +import TableProPluginKit + +// MARK: - App-Side Result Types + +public struct QueryResult: Sendable { + public let columns: [ColumnInfo] + public let rows: [[String?]] + public let rowsAffected: Int + public let executionTime: TimeInterval + public let isTruncated: Bool + public let statusMessage: String? + + public init( + columns: [ColumnInfo], + rows: [[String?]], + rowsAffected: Int, + executionTime: TimeInterval, + isTruncated: Bool = false, + statusMessage: String? = nil + ) { + self.columns = columns + self.rows = rows + self.rowsAffected = rowsAffected + self.executionTime = executionTime + self.isTruncated = isTruncated + self.statusMessage = statusMessage + } +} + +public struct ColumnInfo: Sendable, Identifiable { + public var id: Int { ordinalPosition } + public let name: String + public let typeName: String + public let isPrimaryKey: Bool + public let isNullable: Bool + public let defaultValue: String? + public let comment: String? + public let characterMaxLength: Int? + public let ordinalPosition: Int + + public init( + name: String, + typeName: String, + isPrimaryKey: Bool = false, + isNullable: Bool = true, + defaultValue: String? = nil, + comment: String? = nil, + characterMaxLength: Int? = nil, + ordinalPosition: Int = 0 + ) { + self.name = name + self.typeName = typeName + self.isPrimaryKey = isPrimaryKey + self.isNullable = isNullable + self.defaultValue = defaultValue + self.comment = comment + self.characterMaxLength = characterMaxLength + self.ordinalPosition = ordinalPosition + } +} + +public struct TableInfo: Hashable, Sendable, Identifiable { + public var id: String { name } + public let name: String + public let type: TableKind + public let rowCount: Int? + public let dataSize: Int? + public let comment: String? + + public enum TableKind: String, Sendable { + case table + case view + case materializedView + case systemTable + case sequence + } + + public init( + name: String, + type: TableKind = .table, + rowCount: Int? = nil, + dataSize: Int? = nil, + comment: String? = nil + ) { + self.name = name + self.type = type + self.rowCount = rowCount + self.dataSize = dataSize + self.comment = comment + } +} + +public struct IndexInfo: Sendable { + public let name: String + public let columns: [String] + public let isUnique: Bool + public let isPrimary: Bool + public let type: String + + public init( + name: String, + columns: [String], + isUnique: Bool = false, + isPrimary: Bool = false, + type: String = "BTREE" + ) { + self.name = name + self.columns = columns + self.isUnique = isUnique + self.isPrimary = isPrimary + self.type = type + } +} + +public struct ForeignKeyInfo: Sendable { + public let name: String + public let column: String + public let referencedTable: String + public let referencedColumn: String + public let onDelete: String + public let onUpdate: String + + public init( + name: String, + column: String, + referencedTable: String, + referencedColumn: String, + onDelete: String = "NO ACTION", + onUpdate: String = "NO ACTION" + ) { + self.name = name + self.column = column + self.referencedTable = referencedTable + self.referencedColumn = referencedColumn + self.onDelete = onDelete + self.onUpdate = onUpdate + } +} + +public enum ConnectionStatus: Sendable { + case disconnected + case connecting + case connected + case error(String) +} + +public struct DatabaseError: Error, LocalizedError, Sendable { + public let code: Int? + public let message: String + public let sqlState: String? + + public var errorDescription: String? { message } + + public init(code: Int? = nil, message: String, sqlState: String? = nil) { + self.code = code + self.message = message + self.sqlState = sqlState + } +} + +// MARK: - Mapping from Plugin Types + +public extension QueryResult { + init(from plugin: PluginQueryResult) { + let columnInfos = zip(plugin.columns, plugin.columnTypeNames).enumerated().map { index, pair in + ColumnInfo( + name: pair.0, + typeName: pair.1, + ordinalPosition: index + ) + } + self.init( + columns: columnInfos, + rows: plugin.rows, + rowsAffected: plugin.rowsAffected, + executionTime: plugin.executionTime, + isTruncated: plugin.isTruncated, + statusMessage: plugin.statusMessage + ) + } +} + +public extension TableInfo { + init(from plugin: PluginTableInfo) { + let kind: TableKind + switch plugin.type.uppercased() { + case "TABLE", "BASE TABLE": + kind = .table + case "VIEW": + kind = .view + case "MATERIALIZED VIEW": + kind = .materializedView + case "SYSTEM TABLE": + kind = .systemTable + case "SEQUENCE": + kind = .sequence + default: + kind = .table + } + self.init( + name: plugin.name, + type: kind, + rowCount: plugin.rowCount + ) + } +} + +public extension ColumnInfo { + init(from plugin: PluginColumnInfo, ordinalPosition: Int = 0) { + self.init( + name: plugin.name, + typeName: plugin.dataType, + isPrimaryKey: plugin.isPrimaryKey, + isNullable: plugin.isNullable, + defaultValue: plugin.defaultValue, + comment: plugin.comment, + ordinalPosition: ordinalPosition + ) + } +} + +public extension IndexInfo { + init(from plugin: PluginIndexInfo) { + self.init( + name: plugin.name, + columns: plugin.columns, + isUnique: plugin.isUnique, + isPrimary: plugin.isPrimary, + type: plugin.type + ) + } +} + +public extension ForeignKeyInfo { + init(from plugin: PluginForeignKeyInfo) { + self.init( + name: plugin.name, + column: plugin.column, + referencedTable: plugin.referencedTable, + referencedColumn: plugin.referencedColumn, + onDelete: plugin.onDelete, + onUpdate: plugin.onUpdate + ) + } +} diff --git a/Packages/TableProCore/Sources/TableProModels/SSHConfiguration.swift b/Packages/TableProCore/Sources/TableProModels/SSHConfiguration.swift new file mode 100644 index 000000000..76474bee1 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProModels/SSHConfiguration.swift @@ -0,0 +1,51 @@ +import Foundation + +public struct SSHConfiguration: Codable, Sendable { + public var host: String + public var port: Int + public var username: String + public var authMethod: SSHAuthMethod + public var privateKeyPath: String? + public var jumpHosts: [SSHJumpHost] + + public enum SSHAuthMethod: String, Codable, Sendable { + case password + case publicKey + case agent + } + + public init( + host: String = "", + port: Int = 22, + username: String = "", + authMethod: SSHAuthMethod = .password, + privateKeyPath: String? = nil, + jumpHosts: [SSHJumpHost] = [] + ) { + self.host = host + self.port = port + self.username = username + self.authMethod = authMethod + self.privateKeyPath = privateKeyPath + self.jumpHosts = jumpHosts + } +} + +public struct SSHJumpHost: Codable, Sendable, Identifiable { + public var id: UUID + public var host: String + public var port: Int + public var username: String + + public init( + id: UUID = UUID(), + host: String = "", + port: Int = 22, + username: String = "" + ) { + self.id = id + self.host = host + self.port = port + self.username = username + } +} diff --git a/Packages/TableProCore/Sources/TableProModels/SSLConfiguration.swift b/Packages/TableProCore/Sources/TableProModels/SSLConfiguration.swift new file mode 100644 index 000000000..f47fa1ac3 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProModels/SSLConfiguration.swift @@ -0,0 +1,27 @@ +import Foundation + +public struct SSLConfiguration: Codable, Sendable { + public var mode: SSLMode + public var caCertificatePath: String? + public var clientCertificatePath: String? + public var clientKeyPath: String? + + public enum SSLMode: String, Codable, Sendable { + case disable + case require + case verifyCa + case verifyFull + } + + public init( + mode: SSLMode = .disable, + caCertificatePath: String? = nil, + clientCertificatePath: String? = nil, + clientKeyPath: String? = nil + ) { + self.mode = mode + self.caCertificatePath = caCertificatePath + self.clientCertificatePath = clientCertificatePath + self.clientKeyPath = clientKeyPath + } +} diff --git a/Packages/TableProCore/Sources/TableProModels/SchemaTypes.swift b/Packages/TableProCore/Sources/TableProModels/SchemaTypes.swift new file mode 100644 index 000000000..aed6c03c4 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProModels/SchemaTypes.swift @@ -0,0 +1,95 @@ +import Foundation + +public struct ColumnDefinition: Codable, Sendable { + public var name: String + public var dataType: String + public var isNullable: Bool + public var defaultValue: String? + public var isPrimaryKey: Bool + public var autoIncrement: Bool + public var comment: String? + public var unsigned: Bool + + public init( + name: String, + dataType: String, + isNullable: Bool = true, + defaultValue: String? = nil, + isPrimaryKey: Bool = false, + autoIncrement: Bool = false, + comment: String? = nil, + unsigned: Bool = false + ) { + self.name = name + self.dataType = dataType + self.isNullable = isNullable + self.defaultValue = defaultValue + self.isPrimaryKey = isPrimaryKey + self.autoIncrement = autoIncrement + self.comment = comment + self.unsigned = unsigned + } +} + +public struct IndexDefinition: Codable, Sendable { + public var name: String + public var columns: [String] + public var isUnique: Bool + public var indexType: String? + + public init( + name: String, + columns: [String], + isUnique: Bool = false, + indexType: String? = nil + ) { + self.name = name + self.columns = columns + self.isUnique = isUnique + self.indexType = indexType + } +} + +public struct ForeignKeyDefinition: Codable, Sendable { + public var name: String + public var columns: [String] + public var referencedTable: String + public var referencedColumns: [String] + public var onDelete: String + public var onUpdate: String + + public init( + name: String, + columns: [String], + referencedTable: String, + referencedColumns: [String], + onDelete: String = "NO ACTION", + onUpdate: String = "NO ACTION" + ) { + self.name = name + self.columns = columns + self.referencedTable = referencedTable + self.referencedColumns = referencedColumns + self.onDelete = onDelete + self.onUpdate = onUpdate + } +} + +public struct CreateTableOptions: Codable, Sendable { + public var engine: String? + public var charset: String? + public var collation: String? + public var ifNotExists: Bool + + public init( + engine: String? = nil, + charset: String? = nil, + collation: String? = nil, + ifNotExists: Bool = false + ) { + self.engine = engine + self.charset = charset + self.collation = collation + self.ifNotExists = ifNotExists + } +} diff --git a/Packages/TableProCore/Sources/TableProModels/SortState.swift b/Packages/TableProCore/Sources/TableProModels/SortState.swift new file mode 100644 index 000000000..713c76945 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProModels/SortState.swift @@ -0,0 +1,62 @@ +import Foundation + +public struct SortState: Codable, Sendable { + public var columns: [SortColumn] + + public var isSorting: Bool { !columns.isEmpty } + + public init(columns: [SortColumn] = []) { + self.columns = columns + } + + public mutating func toggle(column: String) { + if let index = columns.firstIndex(where: { $0.name == column }) { + let existing = columns[index] + if existing.ascending { + columns[index] = SortColumn(name: column, ascending: false) + } else { + columns.remove(at: index) + } + } else { + columns = [SortColumn(name: column, ascending: true)] + } + } + + public mutating func clear() { + columns = [] + } +} + +public struct SortColumn: Codable, Sendable { + public let name: String + public let ascending: Bool + + public init(name: String, ascending: Bool) { + self.name = name + self.ascending = ascending + } +} + +public struct PaginationState: Codable, Sendable { + public var pageSize: Int + public var currentPage: Int + public var totalRows: Int? + + public var currentOffset: Int { currentPage * pageSize } + + public var hasNextPage: Bool { + guard let total = totalRows else { return true } + return currentOffset + pageSize < total + } + + public init(pageSize: Int = 200, currentPage: Int = 0, totalRows: Int? = nil) { + self.pageSize = pageSize + self.currentPage = currentPage + self.totalRows = totalRows + } + + public mutating func reset() { + currentPage = 0 + totalRows = nil + } +} diff --git a/Packages/TableProCore/Sources/TableProModels/TableFilter.swift b/Packages/TableProCore/Sources/TableProModels/TableFilter.swift new file mode 100644 index 000000000..7317a9eb3 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProModels/TableFilter.swift @@ -0,0 +1,95 @@ +import Foundation + +public struct TableFilter: Identifiable, Codable, Sendable { + public var id: UUID + public var columnName: String + public var filterOperator: FilterOperator + public var value: String + public var secondValue: String + public var isEnabled: Bool + public var rawSQL: String? + + public static let rawSQLColumn = "__raw_sql__" + + public var isValid: Bool { + if columnName == Self.rawSQLColumn { + guard let sql = rawSQL, !sql.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return false + } + return true + } + guard !columnName.isEmpty else { return false } + switch filterOperator { + case .isNull, .isNotNull: + return true + case .between: + return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !secondValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + default: + return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + } + + public init( + id: UUID = UUID(), + columnName: String = "", + filterOperator: FilterOperator = .equal, + value: String = "", + secondValue: String = "", + isEnabled: Bool = true, + rawSQL: String? = nil + ) { + self.id = id + self.columnName = columnName + self.filterOperator = filterOperator + self.value = value + self.secondValue = secondValue + self.isEnabled = isEnabled + self.rawSQL = rawSQL + } +} + +public enum FilterOperator: String, Codable, Sendable, CaseIterable { + case equal + case notEqual + case greaterThan + case greaterThanOrEqual + case lessThan + case lessThanOrEqual + case like + case notLike + case isNull + case isNotNull + case `in` + case notIn + case between + case contains + case startsWith + case endsWith + + public var sqlSymbol: String { + switch self { + case .equal: return "=" + case .notEqual: return "!=" + case .greaterThan: return ">" + case .greaterThanOrEqual: return ">=" + case .lessThan: return "<" + case .lessThanOrEqual: return "<=" + case .like: return "LIKE" + case .notLike: return "NOT LIKE" + case .isNull: return "IS NULL" + case .isNotNull: return "IS NOT NULL" + case .in: return "IN" + case .notIn: return "NOT IN" + case .between: return "BETWEEN" + case .contains: return "LIKE" + case .startsWith: return "LIKE" + case .endsWith: return "LIKE" + } + } +} + +public enum FilterLogicMode: String, Codable, Sendable { + case and = "AND" + case or = "OR" +} diff --git a/Packages/TableProCore/Sources/TableProPluginKit/CompletionEntry.swift b/Packages/TableProCore/Sources/TableProPluginKit/CompletionEntry.swift new file mode 100644 index 000000000..e290885ad --- /dev/null +++ b/Packages/TableProCore/Sources/TableProPluginKit/CompletionEntry.swift @@ -0,0 +1,32 @@ +import Foundation + +public struct CompletionEntry: Sendable { + public let label: String + public let detail: String? + public let iconName: String + public let kind: CompletionKind + + public enum CompletionKind: String, Sendable { + case keyword + case function + case table + case column + case schema + case database + case snippet + } + + public init(label: String, detail: String? = nil, iconName: String = "text.word.spacing", kind: CompletionKind = .keyword) { + self.label = label + self.detail = detail + self.iconName = iconName + self.kind = kind + } + + public init(label: String, insertText: String) { + self.label = label + self.detail = insertText + self.iconName = "text.word.spacing" + self.kind = .snippet + } +} diff --git a/Packages/TableProCore/Sources/TableProPluginKit/ConnectionField.swift b/Packages/TableProCore/Sources/TableProPluginKit/ConnectionField.swift new file mode 100644 index 000000000..dcee86fb8 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProPluginKit/ConnectionField.swift @@ -0,0 +1,136 @@ +import Foundation + +public enum FieldSection: String, Codable, Sendable { + case authentication + case advanced +} + +public struct FieldVisibilityRule: Codable, Sendable, Equatable { + public let fieldId: String + public let values: [String] + + public init(fieldId: String, values: [String]) { + self.fieldId = fieldId + self.values = values + } +} + +public struct ConnectionField: Codable, Sendable { + public struct IntRange: Codable, Sendable, Equatable { + public let lowerBound: Int + public let upperBound: Int + + public init(_ range: ClosedRange) { + self.lowerBound = range.lowerBound + self.upperBound = range.upperBound + } + + public init(lowerBound: Int, upperBound: Int) { + precondition(lowerBound <= upperBound, "IntRange: lowerBound must be <= upperBound") + self.lowerBound = lowerBound + self.upperBound = upperBound + } + + public var closedRange: ClosedRange { lowerBound...upperBound } + + private enum CodingKeys: String, CodingKey { + case lowerBound, upperBound + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let lower = try container.decode(Int.self, forKey: .lowerBound) + let upper = try container.decode(Int.self, forKey: .upperBound) + guard lower <= upper else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: container.codingPath, + debugDescription: "IntRange lowerBound (\(lower)) must be <= upperBound (\(upper))" + ) + ) + } + self.lowerBound = lower + self.upperBound = upper + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(lowerBound, forKey: .lowerBound) + try container.encode(upperBound, forKey: .upperBound) + } + } + + public enum FieldType: Codable, Sendable, Equatable { + case text + case secure + case dropdown(options: [DropdownOption]) + case number + case toggle + case stepper(range: IntRange) + } + + public struct DropdownOption: Codable, Sendable, Equatable { + public let value: String + public let label: String + + public init(value: String, label: String) { + self.value = value + self.label = label + } + } + + public let id: String + public let label: String + public let placeholder: String + public let isRequired: Bool + public let defaultValue: String? + public let fieldType: FieldType + public let section: FieldSection + public let hidesPassword: Bool + public let visibleWhen: FieldVisibilityRule? + + public var isSecure: Bool { + if case .secure = fieldType { return true } + return false + } + + public init( + id: String, + label: String, + placeholder: String = "", + required: Bool = false, + secure: Bool = false, + defaultValue: String? = nil, + fieldType: FieldType? = nil, + section: FieldSection = .advanced, + hidesPassword: Bool = false, + visibleWhen: FieldVisibilityRule? = nil + ) { + self.id = id + self.label = label + self.placeholder = placeholder + self.isRequired = required + self.defaultValue = defaultValue + self.fieldType = fieldType ?? (secure ? .secure : .text) + self.section = section + self.hidesPassword = hidesPassword + self.visibleWhen = visibleWhen + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + label = try container.decode(String.self, forKey: .label) + placeholder = try container.decodeIfPresent(String.self, forKey: .placeholder) ?? "" + isRequired = try container.decodeIfPresent(Bool.self, forKey: .isRequired) ?? false + defaultValue = try container.decodeIfPresent(String.self, forKey: .defaultValue) + fieldType = try container.decode(FieldType.self, forKey: .fieldType) + section = try container.decodeIfPresent(FieldSection.self, forKey: .section) ?? .advanced + hidesPassword = try container.decodeIfPresent(Bool.self, forKey: .hidesPassword) ?? false + visibleWhen = try container.decodeIfPresent(FieldVisibilityRule.self, forKey: .visibleWhen) + } + + private enum CodingKeys: String, CodingKey { + case id, label, placeholder, isRequired, defaultValue, fieldType, section, hidesPassword, visibleWhen + } +} diff --git a/Packages/TableProCore/Sources/TableProPluginKit/ConnectionMode.swift b/Packages/TableProCore/Sources/TableProPluginKit/ConnectionMode.swift new file mode 100644 index 000000000..36c6b4791 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProPluginKit/ConnectionMode.swift @@ -0,0 +1,7 @@ +import Foundation + +public enum ConnectionMode: String, Codable, Sendable { + case network + case fileBased + case apiOnly +} diff --git a/Packages/TableProCore/Sources/TableProPluginKit/DriverConnectionConfig.swift b/Packages/TableProCore/Sources/TableProPluginKit/DriverConnectionConfig.swift new file mode 100644 index 000000000..7afdb7804 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProPluginKit/DriverConnectionConfig.swift @@ -0,0 +1,26 @@ +import Foundation + +public struct DriverConnectionConfig: Sendable { + public let host: String + public let port: Int + public let username: String + public let password: String + public let database: String + public let additionalFields: [String: String] + + public init( + host: String, + port: Int, + username: String, + password: String, + database: String, + additionalFields: [String: String] = [:] + ) { + self.host = host + self.port = port + self.username = username + self.password = password + self.database = database + self.additionalFields = additionalFields + } +} diff --git a/Packages/TableProCore/Sources/TableProPluginKit/DriverPlugin.swift b/Packages/TableProCore/Sources/TableProPluginKit/DriverPlugin.swift new file mode 100644 index 000000000..fac222125 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProPluginKit/DriverPlugin.swift @@ -0,0 +1,116 @@ +import Foundation + +public protocol DriverPlugin: TableProPlugin { + static var databaseTypeId: String { get } + static var databaseDisplayName: String { get } + static var iconName: String { get } + static var defaultPort: Int { get } + static var additionalConnectionFields: [ConnectionField] { get } + static var additionalDatabaseTypeIds: [String] { get } + + static func driverVariant(for databaseTypeId: String) -> String? + + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver + + // MARK: - UI/Capability Metadata + + static var requiresAuthentication: Bool { get } + static var connectionMode: ConnectionMode { get } + static var urlSchemes: [String] { get } + static var fileExtensions: [String] { get } + static var brandColorHex: String { get } + static var queryLanguageName: String { get } + static var editorLanguage: EditorLanguage { get } + static var supportsForeignKeys: Bool { get } + static var supportsSchemaEditing: Bool { get } + static var supportsDatabaseSwitching: Bool { get } + static var supportsSchemaSwitching: Bool { get } + static var supportsImport: Bool { get } + static var supportsExport: Bool { get } + static var supportsHealthMonitor: Bool { get } + static var systemDatabaseNames: [String] { get } + static var systemSchemaNames: [String] { get } + static var databaseGroupingStrategy: GroupingStrategy { get } + static var defaultGroupName: String { get } + static var columnTypesByCategory: [String: [String]] { get } + static var sqlDialect: SQLDialectDescriptor? { get } + static var statementCompletions: [CompletionEntry] { get } + static var tableEntityName: String { get } + static var supportsCascadeDrop: Bool { get } + static var supportsForeignKeyDisable: Bool { get } + static var immutableColumns: [String] { get } + static var supportsReadOnlyMode: Bool { get } + static var defaultSchemaName: String { get } + static var requiresReconnectForDatabaseSwitch: Bool { get } + static var structureColumnFields: [StructureColumnField] { get } + static var defaultPrimaryKeyColumn: String? { get } + static var supportsQueryProgress: Bool { get } + static var supportsSSH: Bool { get } + static var supportsSSL: Bool { get } + static var navigationModel: NavigationModel { get } + static var explainVariants: [ExplainVariant] { get } + static var pathFieldRole: PathFieldRole { get } + static var isDownloadable: Bool { get } + static var postConnectActions: [PostConnectAction] { get } + static var parameterStyle: ParameterStyle { get } +} + +public extension DriverPlugin { + static var additionalConnectionFields: [ConnectionField] { [] } + static var additionalDatabaseTypeIds: [String] { [] } + static func driverVariant(for databaseTypeId: String) -> String? { nil } + + // MARK: - UI/Capability Metadata Defaults + + static var requiresAuthentication: Bool { true } + static var connectionMode: ConnectionMode { .network } + static var urlSchemes: [String] { [] } + static var fileExtensions: [String] { [] } + static var brandColorHex: String { "#808080" } + static var queryLanguageName: String { "SQL" } + static var editorLanguage: EditorLanguage { .sql } + static var supportsForeignKeys: Bool { true } + static var supportsSchemaEditing: Bool { true } + static var supportsDatabaseSwitching: Bool { true } + static var supportsSchemaSwitching: Bool { false } + static var supportsImport: Bool { true } + static var supportsExport: Bool { true } + static var supportsHealthMonitor: Bool { true } + static var systemDatabaseNames: [String] { [] } + static var systemSchemaNames: [String] { [] } + static var databaseGroupingStrategy: GroupingStrategy { .byDatabase } + static var defaultGroupName: String { "main" } + static var columnTypesByCategory: [String: [String]] { + [ + "Integer": ["INTEGER", "INT", "SMALLINT", "BIGINT", "TINYINT"], + "Float": ["FLOAT", "DOUBLE", "DECIMAL", "NUMERIC", "REAL"], + "String": ["VARCHAR", "CHAR", "TEXT", "NVARCHAR", "NCHAR"], + "Date": ["DATE", "TIME", "DATETIME", "TIMESTAMP"], + "Binary": ["BLOB", "BINARY", "VARBINARY"], + "Boolean": ["BOOLEAN", "BOOL"], + "JSON": ["JSON"] + ] + } + static var sqlDialect: SQLDialectDescriptor? { nil } + static var statementCompletions: [CompletionEntry] { [] } + static var tableEntityName: String { "Tables" } + static var supportsCascadeDrop: Bool { false } + static var supportsForeignKeyDisable: Bool { true } + static var immutableColumns: [String] { [] } + static var supportsReadOnlyMode: Bool { true } + static var defaultSchemaName: String { "public" } + static var requiresReconnectForDatabaseSwitch: Bool { false } + static var structureColumnFields: [StructureColumnField] { + [.name, .type, .nullable, .defaultValue, .autoIncrement, .comment] + } + static var defaultPrimaryKeyColumn: String? { nil } + static var supportsQueryProgress: Bool { false } + static var supportsSSH: Bool { true } + static var supportsSSL: Bool { true } + static var navigationModel: NavigationModel { .standard } + static var explainVariants: [ExplainVariant] { [] } + static var pathFieldRole: PathFieldRole { .database } + static var parameterStyle: ParameterStyle { .questionMark } + static var isDownloadable: Bool { false } + static var postConnectActions: [PostConnectAction] { [] } +} diff --git a/Packages/TableProCore/Sources/TableProPluginKit/EditorLanguage.swift b/Packages/TableProCore/Sources/TableProPluginKit/EditorLanguage.swift new file mode 100644 index 000000000..408b18f83 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProPluginKit/EditorLanguage.swift @@ -0,0 +1,45 @@ +import Foundation + +public enum EditorLanguage: Sendable, Equatable { + case sql + case javascript + case bash + case custom(String) +} + +extension EditorLanguage: Codable { + private enum CodingKeys: String, CodingKey { + case type + case value + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + switch type { + case "sql": self = .sql + case "javascript": self = .javascript + case "bash": self = .bash + case "custom": + let value = try container.decode(String.self, forKey: .value) + self = .custom(value) + default: + self = .custom(type) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .sql: + try container.encode("sql", forKey: .type) + case .javascript: + try container.encode("javascript", forKey: .type) + case .bash: + try container.encode("bash", forKey: .type) + case .custom(let value): + try container.encode("custom", forKey: .type) + try container.encode(value, forKey: .value) + } + } +} diff --git a/Packages/TableProCore/Sources/TableProPluginKit/ExplainVariant.swift b/Packages/TableProCore/Sources/TableProPluginKit/ExplainVariant.swift new file mode 100644 index 000000000..3edd0ab50 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProPluginKit/ExplainVariant.swift @@ -0,0 +1,13 @@ +import Foundation + +public struct ExplainVariant: Sendable, Identifiable { + public let id: String + public let label: String + public let sqlPrefix: String + + public init(id: String, label: String, sqlPrefix: String) { + self.id = id + self.label = label + self.sqlPrefix = sqlPrefix + } +} diff --git a/Packages/TableProCore/Sources/TableProPluginKit/ExportFormatPlugin.swift b/Packages/TableProCore/Sources/TableProPluginKit/ExportFormatPlugin.swift new file mode 100644 index 000000000..1b3255016 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProPluginKit/ExportFormatPlugin.swift @@ -0,0 +1,226 @@ +import Foundation + +public struct PluginExportTable: Sendable { + public let name: String + public let databaseName: String + public let tableType: String + public let optionValues: [Bool] + + public init(name: String, databaseName: String, tableType: String, optionValues: [Bool] = []) { + self.name = name + self.databaseName = databaseName + self.tableType = tableType + self.optionValues = optionValues + } + + public var qualifiedName: String { + databaseName.isEmpty ? name : "\(databaseName).\(name)" + } +} + +public struct PluginExportOptionColumn: Sendable, Identifiable { + public let id: String + public let label: String + public let width: Double + public let defaultValue: Bool + + public init(id: String, label: String, width: Double, defaultValue: Bool = true) { + self.id = id + self.label = label + self.width = width + self.defaultValue = defaultValue + } +} + +public enum PluginExportError: LocalizedError { + case fileWriteFailed(String) + case encodingFailed + case compressionFailed + case exportFailed(String) + + public var errorDescription: String? { + switch self { + case .fileWriteFailed(let path): + return "Failed to write file: \(path)" + case .encodingFailed: + return "Failed to encode content as UTF-8" + case .compressionFailed: + return "Failed to compress data" + case .exportFailed(let message): + return "Export failed: \(message)" + } + } +} + +public struct PluginExportCancellationError: Error, LocalizedError { + public init() {} + public var errorDescription: String? { "Export cancelled" } +} + +public struct PluginSequenceInfo: Sendable { + public let name: String + public let ddl: String + + public init(name: String, ddl: String) { + self.name = name + self.ddl = ddl + } +} + +public struct PluginEnumTypeInfo: Sendable { + public let name: String + public let labels: [String] + + public init(name: String, labels: [String]) { + self.name = name + self.labels = labels + } +} + +public protocol PluginExportDataSource: AnyObject, Sendable { + var databaseTypeId: String { get } + func fetchRows(table: String, databaseName: String, offset: Int, limit: Int) async throws -> PluginQueryResult + func fetchTableDDL(table: String, databaseName: String) async throws -> String + func execute(query: String) async throws -> PluginQueryResult + func quoteIdentifier(_ identifier: String) -> String + func escapeStringLiteral(_ value: String) -> String + func fetchApproximateRowCount(table: String, databaseName: String) async throws -> Int? + func fetchDependentSequences(table: String, databaseName: String) async throws -> [PluginSequenceInfo] + func fetchDependentTypes(table: String, databaseName: String) async throws -> [PluginEnumTypeInfo] +} + +public extension PluginExportDataSource { + func fetchDependentSequences(table: String, databaseName: String) async throws -> [PluginSequenceInfo] { [] } + func fetchDependentTypes(table: String, databaseName: String) async throws -> [PluginEnumTypeInfo] { [] } +} + +public final class PluginExportProgress: @unchecked Sendable { + private let lock = NSLock() + private var _currentTable: String = "" + private var _currentTableIndex: Int = 0 + private var _processedRows: Int = 0 + private var _totalRows: Int = 0 + private var _statusMessage: String = "" + private var _isCancelled: Bool = false + + private let updateInterval: Int = 1_000 + private var internalRowCount: Int = 0 + + public var onUpdate: (@Sendable (String, Int, Int, Int, String) -> Void)? + + public init() {} + + public func setCurrentTable(_ name: String, index: Int) { + lock.lock() + _currentTable = name + _currentTableIndex = index + lock.unlock() + notifyUpdate() + } + + public func incrementRow() { + lock.lock() + internalRowCount += 1 + _processedRows = internalRowCount + let shouldNotify = internalRowCount % updateInterval == 0 + lock.unlock() + if shouldNotify { + notifyUpdate() + } + } + + public func finalizeTable() { + notifyUpdate() + } + + public func setTotalRows(_ count: Int) { + lock.lock() + _totalRows = count + lock.unlock() + } + + public func setStatus(_ message: String) { + lock.lock() + _statusMessage = message + lock.unlock() + notifyUpdate() + } + + public func checkCancellation() throws { + lock.lock() + let cancelled = _isCancelled + lock.unlock() + if cancelled || Task.isCancelled { + throw PluginExportCancellationError() + } + } + + public func cancel() { + lock.lock() + _isCancelled = true + lock.unlock() + } + + public var isCancelled: Bool { + lock.lock() + defer { lock.unlock() } + return _isCancelled + } + + public var processedRows: Int { + lock.lock() + defer { lock.unlock() } + return _processedRows + } + + public var totalRows: Int { + lock.lock() + defer { lock.unlock() } + return _totalRows + } + + private func notifyUpdate() { + lock.lock() + let table = _currentTable + let index = _currentTableIndex + let rows = _processedRows + let total = _totalRows + let status = _statusMessage + lock.unlock() + onUpdate?(table, index, rows, total, status) + } +} + +public protocol ExportFormatPlugin: TableProPlugin { + static var formatId: String { get } + static var formatDisplayName: String { get } + static var defaultFileExtension: String { get } + static var iconName: String { get } + static var supportedDatabaseTypeIds: [String] { get } + static var excludedDatabaseTypeIds: [String] { get } + + static var perTableOptionColumns: [PluginExportOptionColumn] { get } + func defaultTableOptionValues() -> [Bool] + func isTableExportable(optionValues: [Bool]) -> Bool + + var currentFileExtension: String { get } + var warnings: [String] { get } + + func export( + tables: [PluginExportTable], + dataSource: any PluginExportDataSource, + destination: URL, + progress: PluginExportProgress + ) async throws +} + +public extension ExportFormatPlugin { + static var capabilities: [PluginCapability] { [.exportFormat] } + static var supportedDatabaseTypeIds: [String] { [] } + static var excludedDatabaseTypeIds: [String] { [] } + static var perTableOptionColumns: [PluginExportOptionColumn] { [] } + func defaultTableOptionValues() -> [Bool] { [] } + func isTableExportable(optionValues: [Bool]) -> Bool { true } + var currentFileExtension: String { Self.defaultFileExtension } + var warnings: [String] { [] } +} diff --git a/Packages/TableProCore/Sources/TableProPluginKit/GroupingStrategy.swift b/Packages/TableProCore/Sources/TableProPluginKit/GroupingStrategy.swift new file mode 100644 index 000000000..9b6f640c2 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProPluginKit/GroupingStrategy.swift @@ -0,0 +1,7 @@ +import Foundation + +public enum GroupingStrategy: String, Codable, Sendable { + case byDatabase + case bySchema + case flat +} diff --git a/Packages/TableProCore/Sources/TableProPluginKit/ImportFormatPlugin.swift b/Packages/TableProCore/Sources/TableProPluginKit/ImportFormatPlugin.swift new file mode 100644 index 000000000..5cb800c4d --- /dev/null +++ b/Packages/TableProCore/Sources/TableProPluginKit/ImportFormatPlugin.swift @@ -0,0 +1,172 @@ +import Foundation + +public struct PluginImportResult: Sendable { + public let executedStatements: Int + public let executionTime: TimeInterval + public let failedStatement: String? + public let failedLine: Int? + + public init( + executedStatements: Int, + executionTime: TimeInterval, + failedStatement: String? = nil, + failedLine: Int? = nil + ) { + self.executedStatements = executedStatements + self.executionTime = executionTime + self.failedStatement = failedStatement + self.failedLine = failedLine + } +} + +public enum PluginImportError: LocalizedError { + case statementFailed(statement: String, line: Int, underlyingError: any Error) + case rollbackFailed(underlyingError: any Error) + case cancelled + case importFailed(String) + + public var errorDescription: String? { + switch self { + case .statementFailed(_, let line, let error): + return "Import failed at line \(line): \(error.localizedDescription)" + case .rollbackFailed(let error): + return "Transaction rollback failed: \(error.localizedDescription)" + case .cancelled: + return "Import cancelled" + case .importFailed(let message): + return "Import failed: \(message)" + } + } +} + +public struct PluginImportCancellationError: Error, LocalizedError { + public init() {} + public var errorDescription: String? { "Import cancelled" } +} + +public protocol PluginImportSource: AnyObject, Sendable { + func statements() async throws -> AsyncThrowingStream<(statement: String, lineNumber: Int), Error> + func fileURL() -> URL + func fileSizeBytes() -> Int64 +} + +public protocol PluginImportDataSink: AnyObject, Sendable { + var databaseTypeId: String { get } + func execute(statement: String) async throws + func beginTransaction() async throws + func commitTransaction() async throws + func rollbackTransaction() async throws + func disableForeignKeyChecks() async throws + func enableForeignKeyChecks() async throws +} + +public extension PluginImportDataSink { + func disableForeignKeyChecks() async throws {} + func enableForeignKeyChecks() async throws {} +} + +public final class PluginImportProgress: @unchecked Sendable { + private let lock = NSLock() + private var _processedStatements: Int = 0 + private var _estimatedTotalStatements: Int = 0 + private var _statusMessage: String = "" + private var _isCancelled: Bool = false + + private let updateInterval: Int = 500 + private var internalCount: Int = 0 + + public var onUpdate: (@Sendable (Int, Int, String) -> Void)? + + public init() {} + + public func setEstimatedTotal(_ count: Int) { + lock.lock() + _estimatedTotalStatements = count + lock.unlock() + } + + public func incrementStatement() { + lock.lock() + internalCount += 1 + _processedStatements = internalCount + let shouldNotify = internalCount % updateInterval == 0 + lock.unlock() + if shouldNotify { + notifyUpdate() + } + } + + public func setStatus(_ message: String) { + lock.lock() + _statusMessage = message + lock.unlock() + notifyUpdate() + } + + public func checkCancellation() throws { + lock.lock() + let cancelled = _isCancelled + lock.unlock() + if cancelled || Task.isCancelled { + throw PluginImportCancellationError() + } + } + + public func cancel() { + lock.lock() + _isCancelled = true + lock.unlock() + } + + public var isCancelled: Bool { + lock.lock() + defer { lock.unlock() } + return _isCancelled + } + + public var processedStatements: Int { + lock.lock() + defer { lock.unlock() } + return _processedStatements + } + + public var estimatedTotalStatements: Int { + lock.lock() + defer { lock.unlock() } + return _estimatedTotalStatements + } + + public func finalize() { + notifyUpdate() + } + + private func notifyUpdate() { + lock.lock() + let processed = _processedStatements + let total = _estimatedTotalStatements + let status = _statusMessage + lock.unlock() + onUpdate?(processed, total, status) + } +} + +public protocol ImportFormatPlugin: TableProPlugin { + static var formatId: String { get } + static var formatDisplayName: String { get } + static var acceptedFileExtensions: [String] { get } + static var iconName: String { get } + static var supportedDatabaseTypeIds: [String] { get } + static var excludedDatabaseTypeIds: [String] { get } + + func performImport( + source: any PluginImportSource, + sink: any PluginImportDataSink, + progress: PluginImportProgress + ) async throws -> PluginImportResult +} + +public extension ImportFormatPlugin { + static var capabilities: [PluginCapability] { [.importFormat] } + static var supportedDatabaseTypeIds: [String] { [] } + static var excludedDatabaseTypeIds: [String] { [] } +} diff --git a/Packages/TableProCore/Sources/TableProPluginKit/NavigationModel.swift b/Packages/TableProCore/Sources/TableProPluginKit/NavigationModel.swift new file mode 100644 index 000000000..9a5d79f31 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProPluginKit/NavigationModel.swift @@ -0,0 +1,6 @@ +import Foundation + +public enum NavigationModel: String, Sendable { + case standard + case inPlace +} diff --git a/Packages/TableProCore/Sources/TableProPluginKit/PathFieldRole.swift b/Packages/TableProCore/Sources/TableProPluginKit/PathFieldRole.swift new file mode 100644 index 000000000..f262c6aa2 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProPluginKit/PathFieldRole.swift @@ -0,0 +1,8 @@ +import Foundation + +public enum PathFieldRole: String, Sendable { + case database + case serviceName + case filePath + case databaseIndex +} diff --git a/Packages/TableProCore/Sources/TableProPluginKit/PluginCapability.swift b/Packages/TableProCore/Sources/TableProPluginKit/PluginCapability.swift new file mode 100644 index 000000000..371ff0a75 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProPluginKit/PluginCapability.swift @@ -0,0 +1,7 @@ +import Foundation + +public enum PluginCapability: Int, Codable, Sendable { + case databaseDriver + case exportFormat + case importFormat +} diff --git a/Packages/TableProCore/Sources/TableProPluginKit/PluginColumnInfo.swift b/Packages/TableProCore/Sources/TableProPluginKit/PluginColumnInfo.swift new file mode 100644 index 000000000..c50c838d3 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProPluginKit/PluginColumnInfo.swift @@ -0,0 +1,35 @@ +import Foundation + +public struct PluginColumnInfo: Codable, Sendable { + public let name: String + public let dataType: String + public let isNullable: Bool + public let isPrimaryKey: Bool + public let defaultValue: String? + public let extra: String? + public let charset: String? + public let collation: String? + public let comment: String? + + public init( + name: String, + dataType: String, + isNullable: Bool = true, + isPrimaryKey: Bool = false, + defaultValue: String? = nil, + extra: String? = nil, + charset: String? = nil, + collation: String? = nil, + comment: String? = nil + ) { + self.name = name + self.dataType = dataType + self.isNullable = isNullable + self.isPrimaryKey = isPrimaryKey + self.defaultValue = defaultValue + self.extra = extra + self.charset = charset + self.collation = collation + self.comment = comment + } +} diff --git a/Packages/TableProCore/Sources/TableProPluginKit/PluginConcurrencySupport.swift b/Packages/TableProCore/Sources/TableProPluginKit/PluginConcurrencySupport.swift new file mode 100644 index 000000000..cf51a5584 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProPluginKit/PluginConcurrencySupport.swift @@ -0,0 +1,57 @@ +import Foundation + +public func pluginDispatchAsync( + on queue: DispatchQueue, + execute work: @escaping @Sendable () throws -> T +) async throws -> T { + try await withCheckedThrowingContinuation { continuation in + queue.async { + do { + let result = try work() + continuation.resume(returning: result) + } catch { + continuation.resume(throwing: error) + } + } + } +} + +public func pluginDispatchAsync( + on queue: DispatchQueue, + execute work: @escaping @Sendable () throws -> Void +) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + queue.async { + do { + try work() + continuation.resume() + } catch { + continuation.resume(throwing: error) + } + } + } +} + +public func pluginDispatchAsyncCancellable( + 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/Packages/TableProCore/Sources/TableProPluginKit/PluginDatabaseDriver.swift b/Packages/TableProCore/Sources/TableProPluginKit/PluginDatabaseDriver.swift new file mode 100644 index 000000000..9d3afb30b --- /dev/null +++ b/Packages/TableProCore/Sources/TableProPluginKit/PluginDatabaseDriver.swift @@ -0,0 +1,533 @@ +import Foundation + +public enum ParameterStyle: String, Sendable { + case questionMark + case dollar +} + +public struct CellChange: Codable, Sendable { + public let columnIndex: Int + public let columnName: String + public let oldValue: String? + public let newValue: String? + + public init(columnIndex: Int, columnName: String, oldValue: String?, newValue: String?) { + self.columnIndex = columnIndex + self.columnName = columnName + self.oldValue = oldValue + self.newValue = newValue + } +} + +public struct PluginRowChange: Codable, Sendable { + public enum ChangeType: String, Codable, Sendable { + case insert + case update + case delete + } + + public let rowIndex: Int + public let type: ChangeType + public let cellChanges: [CellChange] + public let originalRow: [String?]? + + public init( + rowIndex: Int, + type: ChangeType, + cellChanges: [CellChange], + originalRow: [String?]? + ) { + self.rowIndex = rowIndex + self.type = type + self.cellChanges = cellChanges + self.originalRow = originalRow + } +} + +public protocol PluginDatabaseDriver: AnyObject, Sendable { + // Connection + func connect() async throws + func disconnect() + func ping() async throws + + // Queries + func execute(query: String) async throws -> PluginQueryResult + func fetchRowCount(query: String) async throws -> Int + func fetchRows(query: String, offset: Int, limit: Int) async throws -> PluginQueryResult + + // Schema + func fetchTables(schema: String?) async throws -> [PluginTableInfo] + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] + func fetchTableDDL(table: String, schema: String?) async throws -> String + func fetchViewDefinition(view: String, schema: String?) async throws -> String + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata + func fetchDatabases() async throws -> [String] + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata + + // Schema navigation + var supportsSchemas: Bool { get } + func fetchSchemas() async throws -> [String] + func switchSchema(to schema: String) async throws + var currentSchema: String? { get } + + // Transactions + var supportsTransactions: Bool { get } + func beginTransaction() async throws + func commitTransaction() async throws + func rollbackTransaction() async throws + + // Execution control + func cancelQuery() throws + func applyQueryTimeout(_ seconds: Int) async throws + var serverVersion: String? { get } + var parameterStyle: ParameterStyle { get } + + // Batch operations + func fetchApproximateRowCount(table: String, schema: String?) async throws -> Int? + func fetchAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] + func fetchAllForeignKeys(schema: String?) async throws -> [String: [PluginForeignKeyInfo]] + func fetchAllDatabaseMetadata() async throws -> [PluginDatabaseMetadata] + func fetchDependentTypes(table: String, schema: String?) async throws -> [(name: String, labels: [String])] + func fetchDependentSequences(table: String, schema: String?) async throws -> [(name: String, ddl: String)] + func createDatabase(name: String, charset: String, collation: String?) async throws + func executeParameterized(query: String, parameters: [String?]) async throws -> PluginQueryResult + + // Query building (optional, for NoSQL plugins) + func buildBrowseQuery( + table: String, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int + ) -> String? + func buildFilteredQuery( + table: String, + filters: [(column: String, op: String, value: String)], + logicMode: String, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int + ) -> String? + + // Statement generation (optional, for NoSQL plugins) + func generateStatements( + table: String, + columns: [String], + changes: [PluginRowChange], + insertedRowData: [Int: [String?]], + deletedRowIndices: Set, + insertedRowIndices: Set + ) -> [(statement: String, parameters: [String?])]? + + // Database switching + func switchDatabase(to database: String) async throws + + // DDL schema generation (optional) + func generateAddColumnSQL(table: String, column: PluginColumnDefinition) -> String? + func generateModifyColumnSQL( + table: String, + oldColumn: PluginColumnDefinition, + newColumn: PluginColumnDefinition + ) -> String? + func generateDropColumnSQL(table: String, columnName: String) -> String? + func generateAddIndexSQL(table: String, index: PluginIndexDefinition) -> String? + func generateDropIndexSQL(table: String, indexName: String) -> String? + func generateAddForeignKeySQL(table: String, fk: PluginForeignKeyDefinition) -> String? + func generateDropForeignKeySQL(table: String, constraintName: String) -> String? + func generateModifyPrimaryKeySQL( + table: String, + oldColumns: [String], + newColumns: [String], + constraintName: String? + ) -> [String]? + func generateMoveColumnSQL(table: String, column: PluginColumnDefinition, afterColumn: String?) -> String? + func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? + + // Table operations (optional) + func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String]? + func dropObjectStatement(name: String, objectType: String, schema: String?, cascade: Bool) -> String? + func foreignKeyDisableStatements() -> [String]? + func foreignKeyEnableStatements() -> [String]? + + // EXPLAIN query building (optional) + func buildExplainQuery(_ sql: String) -> String? + + // Identifier quoting + func quoteIdentifier(_ name: String) -> String + + // String escaping + func escapeStringLiteral(_ value: String) -> String + + func createViewTemplate() -> String? + func editViewFallbackTemplate(viewName: String) -> String? + func castColumnToText(_ column: String) -> String + + // All-tables metadata SQL (optional) + func allTablesMetadataSQL(schema: String?) -> String? + + // Default export query (optional) + func defaultExportQuery(table: String) -> String? +} + +// MARK: - Default Implementations + +public extension PluginDatabaseDriver { + var supportsSchemas: Bool { false } + + func fetchSchemas() async throws -> [String] { [] } + + func switchSchema(to schema: String) async throws {} + + var currentSchema: String? { nil } + + var supportsTransactions: Bool { true } + + func beginTransaction() async throws { + _ = try await execute(query: "BEGIN") + } + + func commitTransaction() async throws { + _ = try await execute(query: "COMMIT") + } + + func rollbackTransaction() async throws { + _ = try await execute(query: "ROLLBACK") + } + + func cancelQuery() throws {} + + func applyQueryTimeout(_ seconds: Int) async throws {} + + func ping() async throws { + _ = try await execute(query: "SELECT 1") + } + + var serverVersion: String? { nil } + + var parameterStyle: ParameterStyle { .questionMark } + + func fetchApproximateRowCount(table: String, schema: String?) async throws -> Int? { nil } + + func fetchAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] { + let tables = try await fetchTables(schema: schema) + var result: [String: [PluginColumnInfo]] = [:] + for table in tables { + result[table.name] = try await fetchColumns(table: table.name, schema: schema) + } + return result + } + + func fetchAllForeignKeys(schema: String?) async throws -> [String: [PluginForeignKeyInfo]] { + let tables = try await fetchTables(schema: schema) + var result: [String: [PluginForeignKeyInfo]] = [:] + for table in tables { + let fks = try await fetchForeignKeys(table: table.name, schema: schema) + if !fks.isEmpty { result[table.name] = fks } + } + return result + } + + func fetchAllDatabaseMetadata() async throws -> [PluginDatabaseMetadata] { + let dbs = try await fetchDatabases() + var result: [PluginDatabaseMetadata] = [] + for db in dbs { + do { + result.append(try await fetchDatabaseMetadata(db)) + } catch { + result.append(PluginDatabaseMetadata(name: db)) + } + } + return result + } + + func fetchDependentTypes( + table: String, + schema: String? + ) async throws -> [(name: String, labels: [String])] { [] } + + func fetchDependentSequences( + table: String, + schema: String? + ) async throws -> [(name: String, ddl: String)] { [] } + + func createDatabase(name: String, charset: String, collation: String?) async throws { + throw NSError( + domain: "PluginDatabaseDriver", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "createDatabase not supported"] + ) + } + + func switchDatabase(to database: String) async throws { + throw NSError( + domain: "TableProPluginKit", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "This driver does not support database switching"] + ) + } + + func buildBrowseQuery( + table: String, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int + ) -> String? { nil } + + func buildFilteredQuery( + table: String, + filters: [(column: String, op: String, value: String)], + logicMode: String, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int + ) -> String? { nil } + + func generateStatements( + table: String, + columns: [String], + changes: [PluginRowChange], + insertedRowData: [Int: [String?]], + deletedRowIndices: Set, + insertedRowIndices: Set + ) -> [(statement: String, parameters: [String?])]? { nil } + + func generateAddColumnSQL(table: String, column: PluginColumnDefinition) -> String? { nil } + func generateModifyColumnSQL( + table: String, + oldColumn: PluginColumnDefinition, + newColumn: PluginColumnDefinition + ) -> String? { nil } + func generateDropColumnSQL(table: String, columnName: String) -> String? { nil } + func generateAddIndexSQL(table: String, index: PluginIndexDefinition) -> String? { nil } + func generateDropIndexSQL(table: String, indexName: String) -> String? { nil } + func generateAddForeignKeySQL(table: String, fk: PluginForeignKeyDefinition) -> String? { nil } + func generateDropForeignKeySQL(table: String, constraintName: String) -> String? { nil } + func generateModifyPrimaryKeySQL( + table: String, + oldColumns: [String], + newColumns: [String], + constraintName: String? + ) -> [String]? { nil } + func generateMoveColumnSQL( + table: String, + column: PluginColumnDefinition, + afterColumn: String? + ) -> String? { nil } + func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? { nil } + + func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String]? { nil } + func dropObjectStatement(name: String, objectType: String, schema: String?, cascade: Bool) -> String? { nil } + func foreignKeyDisableStatements() -> [String]? { nil } + func foreignKeyEnableStatements() -> [String]? { nil } + + func buildExplainQuery(_ sql: String) -> String? { nil } + + func createViewTemplate() -> String? { nil } + func editViewFallbackTemplate(viewName: String) -> String? { nil } + func castColumnToText(_ column: String) -> String { column } + func allTablesMetadataSQL(schema: String?) -> String? { nil } + func defaultExportQuery(table: String) -> String? { nil } + + func quoteIdentifier(_ name: String) -> String { + let escaped = name.replacingOccurrences(of: "\"", with: "\"\"") + return "\"\(escaped)\"" + } + + func escapeStringLiteral(_ value: String) -> String { + var result = value + result = result.replacingOccurrences(of: "'", with: "''") + result = result.replacingOccurrences(of: "\0", with: "") + return result + } + + func executeParameterized(query: String, parameters: [String?]) async throws -> PluginQueryResult { + guard !parameters.isEmpty else { + return try await execute(query: query) + } + + let sql: String + switch parameterStyle { + case .questionMark: + sql = Self.substituteQuestionMarks(query: query, parameters: parameters) + case .dollar: + sql = Self.substituteDollarParams(query: query, parameters: parameters) + } + + return try await execute(query: sql) + } + + private static func substituteQuestionMarks(query: String, parameters: [String?]) -> String { + let nsQuery = query as NSString + let length = nsQuery.length + var sql = "" + var paramIndex = 0 + var inSingleQuote = false + var inDoubleQuote = false + var isEscaped = false + var i = 0 + + let backslash: UInt16 = 0x5C + let singleQuote: UInt16 = 0x27 + let doubleQuote: UInt16 = 0x22 + let questionMark: UInt16 = 0x3F + + while i < length { + let char = nsQuery.character(at: i) + + if isEscaped { + isEscaped = false + if let scalar = UnicodeScalar(char) { + sql.append(Character(scalar)) + } else { + sql.append("\u{FFFD}") + } + i += 1 + continue + } + + if char == backslash && (inSingleQuote || inDoubleQuote) { + isEscaped = true + if let scalar = UnicodeScalar(char) { + sql.append(Character(scalar)) + } else { + sql.append("\u{FFFD}") + } + i += 1 + continue + } + + if char == singleQuote && !inDoubleQuote { + inSingleQuote.toggle() + } else if char == doubleQuote && !inSingleQuote { + inDoubleQuote.toggle() + } + + if char == questionMark && !inSingleQuote && !inDoubleQuote && paramIndex < parameters.count { + if let value = parameters[paramIndex] { + sql.append(escapedParameterValue(value)) + } else { + sql.append("NULL") + } + paramIndex += 1 + } else { + if let scalar = UnicodeScalar(char) { + sql.append(Character(scalar)) + } else { + sql.append("\u{FFFD}") + } + } + + i += 1 + } + + return sql + } + + private static func substituteDollarParams(query: String, parameters: [String?]) -> String { + let nsQuery = query as NSString + let length = nsQuery.length + var sql = "" + var i = 0 + var inSingleQuote = false + var inDoubleQuote = false + var isEscaped = false + + while i < length { + let char = nsQuery.character(at: i) + + if isEscaped { + isEscaped = false + if let scalar = UnicodeScalar(char) { + sql.append(Character(scalar)) + } else { + sql.append("\u{FFFD}") + } + i += 1 + continue + } + + let backslash: UInt16 = 0x5C + if char == backslash && (inSingleQuote || inDoubleQuote) { + isEscaped = true + if let scalar = UnicodeScalar(char) { + sql.append(Character(scalar)) + } else { + sql.append("\u{FFFD}") + } + i += 1 + continue + } + + let singleQuote: UInt16 = 0x27 + let doubleQuote: UInt16 = 0x22 + if char == singleQuote && !inDoubleQuote { + inSingleQuote.toggle() + } else if char == doubleQuote && !inSingleQuote { + inDoubleQuote.toggle() + } + + let dollar: UInt16 = 0x24 + if char == dollar && !inSingleQuote && !inDoubleQuote { + var numStr = "" + var j = i + 1 + while j < length { + let digitChar = nsQuery.character(at: j) + if digitChar >= 0x30 && digitChar <= 0x39 { + if let scalar = UnicodeScalar(digitChar) { + numStr.append(Character(scalar)) + } + j += 1 + } else { + break + } + } + if !numStr.isEmpty, let paramNum = Int(numStr), paramNum >= 1, paramNum <= parameters.count { + if let value = parameters[paramNum - 1] { + sql.append(escapedParameterValue(value)) + } else { + sql.append("NULL") + } + i = j + continue + } + } + + if let scalar = UnicodeScalar(char) { + sql.append(Character(scalar)) + } else { + sql.append("\u{FFFD}") + } + i += 1 + } + + return sql + } + + private static func escapedParameterValue(_ value: String) -> String { + if Int64(value) != nil || Double(value) != nil { + return value + } + let escaped = value + .replacingOccurrences(of: "\0", with: "") + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "'", with: "''") + return "'\(escaped)'" + } + + func fetchRowCount(query: String) async throws -> Int { + let result = try await execute(query: "SELECT COUNT(*) FROM (\(query)) _t") + guard let firstRow = result.rows.first, let value = firstRow.first, let countStr = value else { + return 0 + } + return Int(countStr) ?? 0 + } + + func fetchRows(query: String, offset: Int, limit: Int) async throws -> PluginQueryResult { + try await execute(query: "\(query) LIMIT \(limit) OFFSET \(offset)") + } +} diff --git a/Packages/TableProCore/Sources/TableProPluginKit/PluginDatabaseMetadata.swift b/Packages/TableProCore/Sources/TableProPluginKit/PluginDatabaseMetadata.swift new file mode 100644 index 000000000..0d033cd5e --- /dev/null +++ b/Packages/TableProCore/Sources/TableProPluginKit/PluginDatabaseMetadata.swift @@ -0,0 +1,20 @@ +import Foundation + +public struct PluginDatabaseMetadata: Codable, Sendable { + public let name: String + public let tableCount: Int? + public let sizeBytes: Int64? + public let isSystemDatabase: Bool + + public init( + name: String, + tableCount: Int? = nil, + sizeBytes: Int64? = nil, + isSystemDatabase: Bool = false + ) { + self.name = name + self.tableCount = tableCount + self.sizeBytes = sizeBytes + self.isSystemDatabase = isSystemDatabase + } +} diff --git a/Packages/TableProCore/Sources/TableProPluginKit/PluginDriverError.swift b/Packages/TableProCore/Sources/TableProPluginKit/PluginDriverError.swift new file mode 100644 index 000000000..8d8ae7bd7 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProPluginKit/PluginDriverError.swift @@ -0,0 +1,28 @@ +import Foundation + +public protocol PluginDriverError: Error, LocalizedError, Sendable { + var pluginErrorCode: Int? { get } + var pluginSqlState: String? { get } + var pluginMessage: String { get } + var pluginDetail: String? { get } +} + +public extension PluginDriverError { + var pluginErrorCode: Int? { nil } + var pluginSqlState: String? { nil } + var pluginDetail: String? { nil } + + var errorDescription: String? { + var desc = pluginMessage + if let code = pluginErrorCode, code != 0 { + desc = "[\(code)] \(desc)" + } + if let state = pluginSqlState { + desc += " (SQLSTATE: \(state))" + } + if let detail = pluginDetail, !detail.isEmpty { + desc += "\nDetail: \(detail)" + } + return desc + } +} diff --git a/Packages/TableProCore/Sources/TableProPluginKit/PluginForeignKeyInfo.swift b/Packages/TableProCore/Sources/TableProPluginKit/PluginForeignKeyInfo.swift new file mode 100644 index 000000000..bc8810c4c --- /dev/null +++ b/Packages/TableProCore/Sources/TableProPluginKit/PluginForeignKeyInfo.swift @@ -0,0 +1,26 @@ +import Foundation + +public struct PluginForeignKeyInfo: Codable, Sendable { + public let name: String + public let column: String + public let referencedTable: String + public let referencedColumn: String + public let onDelete: String + public let onUpdate: String + + public init( + name: String, + column: String, + referencedTable: String, + referencedColumn: String, + onDelete: String = "NO ACTION", + onUpdate: String = "NO ACTION" + ) { + self.name = name + self.column = column + self.referencedTable = referencedTable + self.referencedColumn = referencedColumn + self.onDelete = onDelete + self.onUpdate = onUpdate + } +} diff --git a/Packages/TableProCore/Sources/TableProPluginKit/PluginIndexInfo.swift b/Packages/TableProCore/Sources/TableProPluginKit/PluginIndexInfo.swift new file mode 100644 index 000000000..baaf1809d --- /dev/null +++ b/Packages/TableProCore/Sources/TableProPluginKit/PluginIndexInfo.swift @@ -0,0 +1,23 @@ +import Foundation + +public struct PluginIndexInfo: Codable, Sendable { + public let name: String + public let columns: [String] + public let isUnique: Bool + public let isPrimary: Bool + public let type: String + + public init( + name: String, + columns: [String], + isUnique: Bool = false, + isPrimary: Bool = false, + type: String = "BTREE" + ) { + self.name = name + self.columns = columns + self.isUnique = isUnique + self.isPrimary = isPrimary + self.type = type + } +} diff --git a/Packages/TableProCore/Sources/TableProPluginKit/PluginQueryResult.swift b/Packages/TableProCore/Sources/TableProPluginKit/PluginQueryResult.swift new file mode 100644 index 000000000..655a236a8 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProPluginKit/PluginQueryResult.swift @@ -0,0 +1,37 @@ +import Foundation + +public struct PluginQueryResult: Codable, Sendable { + public let columns: [String] + public let columnTypeNames: [String] + public let rows: [[String?]] + public let rowsAffected: Int + public let executionTime: TimeInterval + public let isTruncated: Bool + public let statusMessage: String? + + public init( + columns: [String], + columnTypeNames: [String], + rows: [[String?]], + rowsAffected: Int, + executionTime: TimeInterval, + isTruncated: Bool = false, + statusMessage: String? = nil + ) { + self.columns = columns + self.columnTypeNames = columnTypeNames + self.rows = rows + self.rowsAffected = rowsAffected + self.executionTime = executionTime + self.isTruncated = isTruncated + self.statusMessage = statusMessage + } + + public static let empty = PluginQueryResult( + columns: [], + columnTypeNames: [], + rows: [], + rowsAffected: 0, + executionTime: 0 + ) +} diff --git a/Packages/TableProCore/Sources/TableProPluginKit/PluginRowLimits.swift b/Packages/TableProCore/Sources/TableProPluginKit/PluginRowLimits.swift new file mode 100644 index 000000000..079759160 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProPluginKit/PluginRowLimits.swift @@ -0,0 +1,5 @@ +import Foundation + +public enum PluginRowLimits { + public static let defaultMax = 100_000 +} diff --git a/Packages/TableProCore/Sources/TableProPluginKit/PluginSettingsStorage.swift b/Packages/TableProCore/Sources/TableProPluginKit/PluginSettingsStorage.swift new file mode 100644 index 000000000..16590d3ee --- /dev/null +++ b/Packages/TableProCore/Sources/TableProPluginKit/PluginSettingsStorage.swift @@ -0,0 +1,34 @@ +import Foundation + +public final class PluginSettingsStorage: @unchecked Sendable { + private let pluginId: String + private let defaults: UserDefaults + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + public init(pluginId: String, defaults: UserDefaults = .standard) { + self.pluginId = pluginId + self.defaults = defaults + } + + private func key(for optionKey: String) -> String { + "com.TablePro.plugin.\(pluginId).\(optionKey)" + } + + public func save(_ value: T, forKey optionKey: String = "settings") { + guard let data = try? encoder.encode(value) else { return } + defaults.set(data, forKey: key(for: optionKey)) + } + + public func load(_ type: T.Type, forKey optionKey: String = "settings") -> T? { + guard let data = defaults.data(forKey: key(for: optionKey)) else { return nil } + return try? decoder.decode(type, from: data) + } + + public func removeAll() { + let prefix = "com.TablePro.plugin.\(pluginId)." + for key in defaults.dictionaryRepresentation().keys where key.hasPrefix(prefix) { + defaults.removeObject(forKey: key) + } + } +} diff --git a/Packages/TableProCore/Sources/TableProPluginKit/PluginTableInfo.swift b/Packages/TableProCore/Sources/TableProPluginKit/PluginTableInfo.swift new file mode 100644 index 000000000..391d7490f --- /dev/null +++ b/Packages/TableProCore/Sources/TableProPluginKit/PluginTableInfo.swift @@ -0,0 +1,13 @@ +import Foundation + +public struct PluginTableInfo: Codable, Sendable { + public let name: String + public let type: String + public let rowCount: Int? + + public init(name: String, type: String = "TABLE", rowCount: Int? = nil) { + self.name = name + self.type = type + self.rowCount = rowCount + } +} diff --git a/Packages/TableProCore/Sources/TableProPluginKit/PluginTableMetadata.swift b/Packages/TableProCore/Sources/TableProPluginKit/PluginTableMetadata.swift new file mode 100644 index 000000000..c9f0864d8 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProPluginKit/PluginTableMetadata.swift @@ -0,0 +1,29 @@ +import Foundation + +public struct PluginTableMetadata: Codable, Sendable { + public let tableName: String + public let dataSize: Int64? + public let indexSize: Int64? + public let totalSize: Int64? + public let rowCount: Int64? + public let comment: String? + public let engine: String? + + public init( + tableName: String, + dataSize: Int64? = nil, + indexSize: Int64? = nil, + totalSize: Int64? = nil, + rowCount: Int64? = nil, + comment: String? = nil, + engine: String? = nil + ) { + self.tableName = tableName + self.dataSize = dataSize + self.indexSize = indexSize + self.totalSize = totalSize + self.rowCount = rowCount + self.comment = comment + self.engine = engine + } +} diff --git a/Packages/TableProCore/Sources/TableProPluginKit/PostConnectAction.swift b/Packages/TableProCore/Sources/TableProPluginKit/PostConnectAction.swift new file mode 100644 index 000000000..669f4da0f --- /dev/null +++ b/Packages/TableProCore/Sources/TableProPluginKit/PostConnectAction.swift @@ -0,0 +1,6 @@ +import Foundation + +public enum PostConnectAction: Sendable, Equatable { + case selectDatabaseFromLastSession + case selectDatabaseFromConnectionField(fieldId: String) +} diff --git a/Packages/TableProCore/Sources/TableProPluginKit/SQLDialectDescriptor.swift b/Packages/TableProCore/Sources/TableProPluginKit/SQLDialectDescriptor.swift new file mode 100644 index 000000000..db21d447c --- /dev/null +++ b/Packages/TableProCore/Sources/TableProPluginKit/SQLDialectDescriptor.swift @@ -0,0 +1,77 @@ +import Foundation + +public enum AutoLimitStyle: String, Sendable { + case limit + case fetchFirst + case top + case none +} + +public struct SQLDialectDescriptor: Sendable { + public let identifierQuote: String + public let keywords: Set + public let functions: Set + public let dataTypes: Set + public let tableOptions: [String] + + public let regexSyntax: RegexSyntax + public let booleanLiteralStyle: BooleanLiteralStyle + public let likeEscapeStyle: LikeEscapeStyle + public let paginationStyle: PaginationStyle + public let offsetFetchOrderBy: String + public let requiresBackslashEscaping: Bool + + public let autoLimitStyle: AutoLimitStyle + + public enum RegexSyntax: String, Sendable { + case regexp + case tilde + case regexpMatches + case match + case regexpLike + case unsupported + } + + public enum BooleanLiteralStyle: String, Sendable { + case truefalse + case numeric + } + + public enum LikeEscapeStyle: String, Sendable { + case implicit + case explicit + } + + public enum PaginationStyle: String, Sendable { + case limit + case offsetFetch + } + + public init( + identifierQuote: String, + keywords: Set, + functions: Set, + dataTypes: Set, + tableOptions: [String] = [], + regexSyntax: RegexSyntax = .unsupported, + booleanLiteralStyle: BooleanLiteralStyle = .numeric, + likeEscapeStyle: LikeEscapeStyle = .explicit, + paginationStyle: PaginationStyle = .limit, + offsetFetchOrderBy: String = "ORDER BY (SELECT NULL)", + requiresBackslashEscaping: Bool = false, + autoLimitStyle: AutoLimitStyle = .limit + ) { + self.identifierQuote = identifierQuote + self.keywords = keywords + self.functions = functions + self.dataTypes = dataTypes + self.tableOptions = tableOptions + self.regexSyntax = regexSyntax + self.booleanLiteralStyle = booleanLiteralStyle + self.likeEscapeStyle = likeEscapeStyle + self.paginationStyle = paginationStyle + self.offsetFetchOrderBy = offsetFetchOrderBy + self.requiresBackslashEscaping = requiresBackslashEscaping + self.autoLimitStyle = autoLimitStyle + } +} diff --git a/Packages/TableProCore/Sources/TableProPluginKit/SchemaTypes.swift b/Packages/TableProCore/Sources/TableProPluginKit/SchemaTypes.swift new file mode 100644 index 000000000..5b8daf08c --- /dev/null +++ b/Packages/TableProCore/Sources/TableProPluginKit/SchemaTypes.swift @@ -0,0 +1,113 @@ +import Foundation + +public struct PluginColumnDefinition: Sendable { + public let name: String + public let dataType: String + public let isNullable: Bool + public let defaultValue: String? + public let isPrimaryKey: Bool + public let autoIncrement: Bool + public let comment: String? + public let unsigned: Bool + public let onUpdate: String? + + public init( + name: String, + dataType: String, + isNullable: Bool = true, + defaultValue: String? = nil, + isPrimaryKey: Bool = false, + autoIncrement: Bool = false, + comment: String? = nil, + unsigned: Bool = false, + onUpdate: String? = nil + ) { + self.name = name + self.dataType = dataType + self.isNullable = isNullable + self.defaultValue = defaultValue + self.isPrimaryKey = isPrimaryKey + self.autoIncrement = autoIncrement + self.comment = comment + self.unsigned = unsigned + self.onUpdate = onUpdate + } +} + +public struct PluginIndexDefinition: Sendable { + public let name: String + public let columns: [String] + public let isUnique: Bool + public let indexType: String? + + public init( + name: String, + columns: [String], + isUnique: Bool = false, + indexType: String? = nil + ) { + self.name = name + self.columns = columns + self.isUnique = isUnique + self.indexType = indexType + } +} + +public struct PluginForeignKeyDefinition: Sendable { + public let name: String + public let columns: [String] + public let referencedTable: String + public let referencedColumns: [String] + public let onDelete: String + public let onUpdate: String + + public init( + name: String, + columns: [String], + referencedTable: String, + referencedColumns: [String], + onDelete: String = "NO ACTION", + onUpdate: String = "NO ACTION" + ) { + self.name = name + self.columns = columns + self.referencedTable = referencedTable + self.referencedColumns = referencedColumns + self.onDelete = onDelete + self.onUpdate = onUpdate + } +} + +public struct PluginCreateTableDefinition: Sendable { + public let tableName: String + public let columns: [PluginColumnDefinition] + public let indexes: [PluginIndexDefinition] + public let foreignKeys: [PluginForeignKeyDefinition] + public let primaryKeyColumns: [String] + public let engine: String? + public let charset: String? + public let collation: String? + public let ifNotExists: Bool + + public init( + tableName: String, + columns: [PluginColumnDefinition], + indexes: [PluginIndexDefinition] = [], + foreignKeys: [PluginForeignKeyDefinition] = [], + primaryKeyColumns: [String] = [], + engine: String? = nil, + charset: String? = nil, + collation: String? = nil, + ifNotExists: Bool = false + ) { + self.tableName = tableName + self.columns = columns + self.indexes = indexes + self.foreignKeys = foreignKeys + self.primaryKeyColumns = primaryKeyColumns + self.engine = engine + self.charset = charset + self.collation = collation + self.ifNotExists = ifNotExists + } +} diff --git a/Packages/TableProCore/Sources/TableProPluginKit/SettablePlugin.swift b/Packages/TableProCore/Sources/TableProPluginKit/SettablePlugin.swift new file mode 100644 index 000000000..b3a30ec8b --- /dev/null +++ b/Packages/TableProCore/Sources/TableProPluginKit/SettablePlugin.swift @@ -0,0 +1,9 @@ +import Foundation + +public protocol SettablePlugin: AnyObject { + associatedtype Settings: Codable & Equatable + + static var settingsStorageId: String { get } + + var settings: Settings { get set } +} diff --git a/Packages/TableProCore/Sources/TableProPluginKit/StructureColumnField.swift b/Packages/TableProCore/Sources/TableProPluginKit/StructureColumnField.swift new file mode 100644 index 000000000..938da8187 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProPluginKit/StructureColumnField.swift @@ -0,0 +1,23 @@ +import Foundation + +public enum StructureColumnField: String, Sendable, CaseIterable { + case name + case type + case nullable + case defaultValue + case primaryKey + case autoIncrement + case comment + + public var displayName: String { + switch self { + case .name: return "Name" + case .type: return "Type" + case .nullable: return "Nullable" + case .defaultValue: return "Default" + case .primaryKey: return "Primary Key" + case .autoIncrement: return "Auto Inc" + case .comment: return "Comment" + } + } +} diff --git a/Packages/TableProCore/Sources/TableProPluginKit/TableProPlugin.swift b/Packages/TableProCore/Sources/TableProPluginKit/TableProPlugin.swift new file mode 100644 index 000000000..84001e6e1 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProPluginKit/TableProPlugin.swift @@ -0,0 +1,15 @@ +import Foundation + +public protocol TableProPlugin: AnyObject { + static var pluginName: String { get } + static var pluginVersion: String { get } + static var pluginDescription: String { get } + static var capabilities: [PluginCapability] { get } + static var dependencies: [String] { get } + + init() +} + +public extension TableProPlugin { + static var dependencies: [String] { [] } +} diff --git a/Packages/TableProCore/Sources/TableProQuery/FilterSQLGenerator.swift b/Packages/TableProCore/Sources/TableProQuery/FilterSQLGenerator.swift new file mode 100644 index 000000000..4a7e10307 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProQuery/FilterSQLGenerator.swift @@ -0,0 +1,121 @@ +import Foundation +import TableProModels +import TableProPluginKit + +public struct FilterSQLGenerator: Sendable { + private let dialect: SQLDialectDescriptor + + public init(dialect: SQLDialectDescriptor) { + self.dialect = dialect + } + + public func generateWhereClause( + from filters: [TableFilter], + logicMode: FilterLogicMode + ) -> String { + let activeFilters = filters.filter { $0.isEnabled && $0.isValid } + guard !activeFilters.isEmpty else { return "" } + + let conditions = activeFilters.compactMap { generateCondition(for: $0) } + guard !conditions.isEmpty else { return "" } + + let joined = conditions.joined(separator: " \(logicMode.rawValue) ") + return "WHERE \(joined)" + } + + private func generateCondition(for filter: TableFilter) -> String? { + if filter.columnName == TableFilter.rawSQLColumn { + guard let rawSQL = filter.rawSQL, !rawSQL.isEmpty else { return nil } + return rawSQL + } + + let quotedColumn = quoteIdentifier(filter.columnName) + let escapedValue = escapeValue(filter.value) + + switch filter.filterOperator { + case .equal: + return "\(quotedColumn) = \(escapedValue)" + case .notEqual: + return "\(quotedColumn) != \(escapedValue)" + case .greaterThan: + return "\(quotedColumn) > \(escapedValue)" + case .greaterThanOrEqual: + return "\(quotedColumn) >= \(escapedValue)" + case .lessThan: + return "\(quotedColumn) < \(escapedValue)" + case .lessThanOrEqual: + return "\(quotedColumn) <= \(escapedValue)" + case .like: + return "\(quotedColumn) LIKE \(escapedValue)\(likeEscape)" + case .notLike: + return "\(quotedColumn) NOT LIKE \(escapedValue)\(likeEscape)" + case .isNull: + return "\(quotedColumn) IS NULL" + case .isNotNull: + return "\(quotedColumn) IS NOT NULL" + case .in: + let values = parseInValues(filter.value) + return "\(quotedColumn) IN (\(values))" + case .notIn: + let values = parseInValues(filter.value) + return "\(quotedColumn) NOT IN (\(values))" + case .between: + let escapedSecond = escapeValue(filter.secondValue) + return "\(quotedColumn) BETWEEN \(escapedValue) AND \(escapedSecond)" + case .contains: + let pattern = escapeLikePattern(filter.value) + return "\(quotedColumn) LIKE '%\(pattern)%'\(likeEscape)" + case .startsWith: + let pattern = escapeLikePattern(filter.value) + return "\(quotedColumn) LIKE '\(pattern)%'\(likeEscape)" + case .endsWith: + let pattern = escapeLikePattern(filter.value) + return "\(quotedColumn) LIKE '%\(pattern)'\(likeEscape)" + } + } + + private var likeEscape: String { + switch dialect.likeEscapeStyle { + case .explicit: + return " ESCAPE '\\'" + case .implicit: + return "" + } + } + + private func quoteIdentifier(_ name: String) -> String { + let q = dialect.identifierQuote + let escaped = name.replacingOccurrences(of: q, with: "\(q)\(q)") + return "\(q)\(escaped)\(q)" + } + + private func escapeValue(_ value: String) -> String { + if Int64(value) != nil || Double(value) != nil { + return value + } + let escaped = value + .replacingOccurrences(of: "'", with: "''") + .replacingOccurrences(of: "\0", with: "") + return "'\(escaped)'" + } + + private func escapeLikePattern(_ value: String) -> String { + var result = value + .replacingOccurrences(of: "'", with: "''") + .replacingOccurrences(of: "\0", with: "") + if dialect.requiresBackslashEscaping { + result = result.replacingOccurrences(of: "\\", with: "\\\\") + } + result = result.replacingOccurrences(of: "%", with: "\\%") + result = result.replacingOccurrences(of: "_", with: "\\_") + return result + } + + private func parseInValues(_ value: String) -> String { + let parts = value.components(separatedBy: ",") + return parts.map { part in + let trimmed = part.trimmingCharacters(in: .whitespaces) + return escapeValue(trimmed) + }.joined(separator: ", ") + } +} diff --git a/Packages/TableProCore/Sources/TableProQuery/RowParser.swift b/Packages/TableProCore/Sources/TableProQuery/RowParser.swift new file mode 100644 index 000000000..669405336 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProQuery/RowParser.swift @@ -0,0 +1,131 @@ +import Foundation + +public protocol RowDataParser: Sendable { + func parse(text: String, columns: [String]) throws -> [[String?]] +} + +public enum RowParserError: Error, LocalizedError { + case invalidFormat(String) + case columnCountMismatch(expected: Int, got: Int, row: Int) + + public var errorDescription: String? { + switch self { + case .invalidFormat(let message): + return "Invalid format: \(message)" + case .columnCountMismatch(let expected, let got, let row): + return "Row \(row): expected \(expected) columns but got \(got)" + } + } +} + +public struct TSVRowParser: RowDataParser, Sendable { + public init() {} + + public func parse(text: String, columns: [String]) throws -> [[String?]] { + let lines = text.components(separatedBy: .newlines) + var result: [[String?]] = [] + + for (index, line) in lines.enumerated() { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { continue } + + let values = line.components(separatedBy: "\t") + let row: [String?] = values.map { value in + let trimmedValue = value.trimmingCharacters(in: .whitespaces) + if trimmedValue == "NULL" || trimmedValue == "\\N" { + return nil + } + return trimmedValue + } + + if !columns.isEmpty && row.count != columns.count { + throw RowParserError.columnCountMismatch( + expected: columns.count, + got: row.count, + row: index + 1 + ) + } + + result.append(row) + } + + return result + } +} + +public struct CSVRowParser: RowDataParser, Sendable { + public init() {} + + public func parse(text: String, columns: [String]) throws -> [[String?]] { + let lines = text.components(separatedBy: .newlines) + var result: [[String?]] = [] + + for (index, line) in lines.enumerated() { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { continue } + + let values = parseCSVLine(line) + let row: [String?] = values.map { value in + let trimmedValue = value.trimmingCharacters(in: .whitespaces) + if trimmedValue == "NULL" { + return nil + } + return trimmedValue + } + + if !columns.isEmpty && row.count != columns.count { + throw RowParserError.columnCountMismatch( + expected: columns.count, + got: row.count, + row: index + 1 + ) + } + + result.append(row) + } + + return result + } + + private func parseCSVLine(_ line: String) -> [String] { + var fields: [String] = [] + var current = "" + var inQuotes = false + var i = line.startIndex + + while i < line.endIndex { + let char = line[i] + + if inQuotes { + if char == "\"" { + let next = line.index(after: i) + if next < line.endIndex && line[next] == "\"" { + current.append("\"") + i = line.index(after: next) + } else { + inQuotes = false + i = line.index(after: i) + } + } else { + current.append(char) + i = line.index(after: i) + } + } else { + if char == "\"" { + inQuotes = true + i = line.index(after: i) + } else if char == "," { + fields.append(current) + current = "" + i = line.index(after: i) + } else { + current.append(char) + i = line.index(after: i) + } + } + } + + fields.append(current) + return fields + } +} diff --git a/Packages/TableProCore/Sources/TableProQuery/SQLDialectProvider.swift b/Packages/TableProCore/Sources/TableProQuery/SQLDialectProvider.swift new file mode 100644 index 000000000..030044f5a --- /dev/null +++ b/Packages/TableProCore/Sources/TableProQuery/SQLDialectProvider.swift @@ -0,0 +1,44 @@ +import Foundation +import TableProModels +import TableProPluginKit + +public protocol SQLDialectProvider: Sendable { + func dialect(for type: DatabaseType) -> SQLDialectDescriptor? +} + +public struct PluginDialectAdapter: SQLDialectProvider, Sendable { + private let resolveDialect: @Sendable (DatabaseType) -> SQLDialectDescriptor? + + public init(resolveDialect: @escaping @Sendable (DatabaseType) -> SQLDialectDescriptor?) { + self.resolveDialect = resolveDialect + } + + public func dialect(for type: DatabaseType) -> SQLDialectDescriptor? { + resolveDialect(type) + } +} + +public enum SQLDialectFactory { + public static func defaultDialect() -> SQLDialectDescriptor { + SQLDialectDescriptor( + identifierQuote: "\"", + keywords: ["SELECT", "FROM", "WHERE", "INSERT", "UPDATE", "DELETE", "CREATE", "DROP", "ALTER", + "TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA", "INTO", "VALUES", "SET", + "AND", "OR", "NOT", "NULL", "IS", "IN", "LIKE", "BETWEEN", "EXISTS", + "JOIN", "LEFT", "RIGHT", "INNER", "OUTER", "ON", "AS", "ORDER", "BY", + "GROUP", "HAVING", "LIMIT", "OFFSET", "UNION", "ALL", "DISTINCT", + "ASC", "DESC", "BEGIN", "COMMIT", "ROLLBACK", "TRANSACTION"], + functions: ["COUNT", "SUM", "AVG", "MIN", "MAX", "COALESCE", "IFNULL", "NULLIF", + "UPPER", "LOWER", "TRIM", "LENGTH", "SUBSTRING", "CONCAT", + "NOW", "CURRENT_TIMESTAMP", "CURRENT_DATE", "CURRENT_TIME", + "CAST", "CONVERT", "ABS", "ROUND", "CEIL", "FLOOR"], + dataTypes: ["INTEGER", "INT", "BIGINT", "SMALLINT", "TINYINT", + "VARCHAR", "CHAR", "TEXT", "NVARCHAR", + "FLOAT", "DOUBLE", "DECIMAL", "NUMERIC", "REAL", + "DATE", "TIME", "DATETIME", "TIMESTAMP", + "BOOLEAN", "BOOL", + "BLOB", "BINARY", "VARBINARY", + "JSON"] + ) + } +} diff --git a/Packages/TableProCore/Sources/TableProQuery/SQLStatementGenerator.swift b/Packages/TableProCore/Sources/TableProQuery/SQLStatementGenerator.swift new file mode 100644 index 000000000..4b73ed0c9 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProQuery/SQLStatementGenerator.swift @@ -0,0 +1,66 @@ +import Foundation +import TableProPluginKit + +public struct SQLStatementGenerator: Sendable { + private let dialect: SQLDialectDescriptor + + public init(dialect: SQLDialectDescriptor) { + self.dialect = dialect + } + + public func generateInsert(table: String, columns: [String], values: [String?]) -> String { + let quotedTable = quoteIdentifier(table) + let quotedColumns = columns.map { quoteIdentifier($0) }.joined(separator: ", ") + let formattedValues = values.map { formatValue($0) }.joined(separator: ", ") + return "INSERT INTO \(quotedTable) (\(quotedColumns)) VALUES (\(formattedValues))" + } + + public func generateUpdate( + table: String, + changes: [String: String?], + where whereConditions: [String: String] + ) -> String { + let quotedTable = quoteIdentifier(table) + + let setClauses = changes.map { key, value in + "\(quoteIdentifier(key)) = \(formatValue(value))" + }.joined(separator: ", ") + + let whereClauses = whereConditions.map { key, value in + "\(quoteIdentifier(key)) = \(formatWhereValue(value))" + }.joined(separator: " AND ") + + return "UPDATE \(quotedTable) SET \(setClauses) WHERE \(whereClauses)" + } + + public func generateDelete(table: String, where whereConditions: [String: String]) -> String { + let quotedTable = quoteIdentifier(table) + + let whereClauses = whereConditions.map { key, value in + "\(quoteIdentifier(key)) = \(formatWhereValue(value))" + }.joined(separator: " AND ") + + return "DELETE FROM \(quotedTable) WHERE \(whereClauses)" + } + + private func quoteIdentifier(_ name: String) -> String { + let q = dialect.identifierQuote + let escaped = name.replacingOccurrences(of: q, with: "\(q)\(q)") + return "\(q)\(escaped)\(q)" + } + + private func formatValue(_ value: String?) -> String { + guard let value else { return "NULL" } + if Int64(value) != nil || Double(value) != nil { + return value + } + let escaped = value + .replacingOccurrences(of: "'", with: "''") + .replacingOccurrences(of: "\0", with: "") + return "'\(escaped)'" + } + + private func formatWhereValue(_ value: String) -> String { + formatValue(value) + } +} diff --git a/Packages/TableProCore/Sources/TableProQuery/TableQueryBuilder.swift b/Packages/TableProCore/Sources/TableProQuery/TableQueryBuilder.swift new file mode 100644 index 000000000..da33cb6cb --- /dev/null +++ b/Packages/TableProCore/Sources/TableProQuery/TableQueryBuilder.swift @@ -0,0 +1,119 @@ +import Foundation +import TableProModels +import TableProPluginKit + +public struct TableQueryBuilder: Sendable { + private let dialect: SQLDialectDescriptor? + private let pluginDriver: (any PluginDatabaseDriver)? + + public init( + dialect: SQLDialectDescriptor? = nil, + pluginDriver: (any PluginDatabaseDriver)? = nil + ) { + self.dialect = dialect + self.pluginDriver = pluginDriver + } + + public func buildBrowseQuery( + tableName: String, + sortState: SortState = SortState(), + limit: Int, + offset: Int + ) -> String { + if let driver = pluginDriver { + let sortColumns = sortState.columns.enumerated().map { (index, col) in + (columnIndex: index, ascending: col.ascending) + } + if let query = driver.buildBrowseQuery( + table: tableName, + sortColumns: sortColumns, + columns: [], + limit: limit, + offset: offset + ) { + return query + } + } + + let quoted = quoteIdentifier(tableName) + var sql = "SELECT * FROM \(quoted)" + + if sortState.isSorting { + sql += " " + buildOrderByClause(sortState: sortState) + } + + sql += " " + buildPaginationClause(limit: limit, offset: offset) + return sql + } + + public func buildFilteredQuery( + tableName: String, + filters: [TableFilter], + logicMode: FilterLogicMode = .and, + sortState: SortState = SortState(), + limit: Int, + offset: Int + ) -> String { + if let driver = pluginDriver { + let filterTuples = filters.filter { $0.isEnabled && $0.isValid }.map { f in + (column: f.columnName, op: f.filterOperator.sqlSymbol, value: f.value) + } + let sortColumns = sortState.columns.enumerated().map { (index, col) in + (columnIndex: index, ascending: col.ascending) + } + if let query = driver.buildFilteredQuery( + table: tableName, + filters: filterTuples, + logicMode: logicMode.rawValue, + sortColumns: sortColumns, + columns: [], + limit: limit, + offset: offset + ) { + return query + } + } + + let quoted = quoteIdentifier(tableName) + var sql = "SELECT * FROM \(quoted)" + + if let dialect { + let generator = FilterSQLGenerator(dialect: dialect) + let whereClause = generator.generateWhereClause(from: filters, logicMode: logicMode) + if !whereClause.isEmpty { + sql += " \(whereClause)" + } + } + + if sortState.isSorting { + sql += " " + buildOrderByClause(sortState: sortState) + } + + sql += " " + buildPaginationClause(limit: limit, offset: offset) + return sql + } + + private func buildOrderByClause(sortState: SortState) -> String { + let parts = sortState.columns.map { col in + "\(quoteIdentifier(col.name)) \(col.ascending ? "ASC" : "DESC")" + } + return "ORDER BY \(parts.joined(separator: ", "))" + } + + private func buildPaginationClause(limit: Int, offset: Int) -> String { + let style = dialect?.paginationStyle ?? .limit + switch style { + case .limit: + return "LIMIT \(limit) OFFSET \(offset)" + case .offsetFetch: + let orderBy = dialect?.offsetFetchOrderBy ?? "ORDER BY (SELECT NULL)" + return "\(orderBy) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY" + } + } + + private func quoteIdentifier(_ name: String) -> String { + let q = dialect?.identifierQuote ?? "\"" + let escaped = name.replacingOccurrences(of: q, with: "\(q)\(q)") + return "\(q)\(escaped)\(q)" + } +} diff --git a/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTests.swift b/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTests.swift new file mode 100644 index 000000000..fee5b216b --- /dev/null +++ b/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTests.swift @@ -0,0 +1,161 @@ +import Testing +import Foundation +@testable import TableProDatabase +@testable import TableProModels +@testable import TableProPluginKit + +// MARK: - Mock Types + +private final class MockPluginDriver: PluginDatabaseDriver, @unchecked Sendable { + var connected = false + var disconnected = false + + func connect() async throws { + connected = true + } + + func disconnect() { + disconnected = true + } + + func execute(query: String) async throws -> PluginQueryResult { + .empty + } + + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } + func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } + func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + PluginTableMetadata(tableName: table) + } + func fetchDatabases() async throws -> [String] { [] } + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } +} + +private final class MockDriverPlugin: DriverPlugin { + static var pluginName: String { "MockDriver" } + static var pluginVersion: String { "1.0" } + static var pluginDescription: String { "Mock" } + static var capabilities: [PluginCapability] { [.databaseDriver] } + + static var databaseTypeId: String { "mock" } + static var databaseDisplayName: String { "Mock DB" } + static var iconName: String { "server.rack" } + static var defaultPort: Int { 5432 } + + required init() {} + + private static let sharedDriver = MockPluginDriver() + + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { + MockDriverPlugin.sharedDriver + } +} + +private final class MockPluginLoader: PluginLoader, Sendable { + func availablePlugins() -> [any DriverPlugin] { + [MockDriverPlugin()] + } + + func driverPlugin(for typeId: String) -> (any DriverPlugin)? { + if typeId == "mock" { return MockDriverPlugin() } + return nil + } +} + +private final class MockSecureStore: SecureStore, Sendable { + private let passwords: [String: String] + + init(passwords: [String: String] = [:]) { + self.passwords = passwords + } + + func store(_ value: String, forKey key: String) throws {} + + func retrieve(forKey key: String) throws -> String? { + passwords[key] + } + + func delete(forKey key: String) throws {} +} + +@Suite("ConnectionManager Tests") +struct ConnectionManagerTests { + @Test("Connect creates a session") + func connectCreatesSession() async throws { + let loader = MockPluginLoader() + let store = MockSecureStore() + let manager = ConnectionManager(pluginLoader: loader, secureStore: store) + + let connection = DatabaseConnection( + name: "Test", + type: DatabaseType(rawValue: "mock"), + host: "localhost", + port: 5432 + ) + + let session = try await manager.connect(connection) + #expect(session.connectionId == connection.id) + #expect(session.activeDatabase == connection.database) + + let retrieved = manager.session(for: connection.id) + #expect(retrieved != nil) + } + + @Test("Disconnect removes session") + func disconnectRemovesSession() async throws { + let loader = MockPluginLoader() + let store = MockSecureStore() + let manager = ConnectionManager(pluginLoader: loader, secureStore: store) + + let connection = DatabaseConnection( + name: "Test", + type: DatabaseType(rawValue: "mock") + ) + + _ = try await manager.connect(connection) + await manager.disconnect(connection.id) + + let session = manager.session(for: connection.id) + #expect(session == nil) + } + + @Test("Connect with unknown plugin throws error") + func connectUnknownPlugin() async throws { + let loader = MockPluginLoader() + let store = MockSecureStore() + let manager = ConnectionManager(pluginLoader: loader, secureStore: store) + + let connection = DatabaseConnection( + name: "Test", + type: DatabaseType(rawValue: "nonexistent") + ) + + await #expect(throws: ConnectionError.self) { + _ = try await manager.connect(connection) + } + } + + @Test("Connect with SSH but no provider throws error") + func connectSSHNoProvider() async throws { + let loader = MockPluginLoader() + let store = MockSecureStore() + let manager = ConnectionManager(pluginLoader: loader, secureStore: store, sshProvider: nil) + + var connection = DatabaseConnection( + name: "Test", + type: DatabaseType(rawValue: "mock") + ) + connection.sshEnabled = true + connection.sshConfiguration = SSHConfiguration(host: "jump.example.com") + + await #expect(throws: ConnectionError.self) { + _ = try await manager.connect(connection) + } + } +} diff --git a/Packages/TableProCore/Tests/TableProDatabaseTests/PluginDriverAdapterTests.swift b/Packages/TableProCore/Tests/TableProDatabaseTests/PluginDriverAdapterTests.swift new file mode 100644 index 000000000..adc8f7295 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProDatabaseTests/PluginDriverAdapterTests.swift @@ -0,0 +1,120 @@ +import Testing +import Foundation +@testable import TableProDatabase +@testable import TableProModels +@testable import TableProPluginKit + +private final class StubPluginDriver: PluginDatabaseDriver, @unchecked Sendable { + var connectCalled = false + var disconnectCalled = false + + func connect() async throws { + connectCalled = true + } + + func disconnect() { + disconnectCalled = true + } + + func execute(query: String) async throws -> PluginQueryResult { + PluginQueryResult( + columns: ["id", "name"], + columnTypeNames: ["INT", "VARCHAR"], + rows: [["1", "Alice"]], + rowsAffected: 0, + executionTime: 0.01 + ) + } + + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { + [PluginTableInfo(name: "users", type: "TABLE", rowCount: 42)] + } + + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { + [PluginColumnInfo(name: "id", dataType: "INT", isPrimaryKey: true)] + } + + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { + [PluginIndexInfo(name: "pk_id", columns: ["id"], isPrimary: true)] + } + + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } + func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } + func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + PluginTableMetadata(tableName: table) + } + func fetchDatabases() async throws -> [String] { ["db1", "db2"] } + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } +} + +@Suite("PluginDriverAdapter Tests") +struct PluginDriverAdapterTests { + @Test("Execute maps PluginQueryResult to QueryResult") + func executeMapsResult() async throws { + let stub = StubPluginDriver() + let adapter = PluginDriverAdapter(pluginDriver: stub) + + let result = try await adapter.execute(query: "SELECT 1") + #expect(result.columns.count == 2) + #expect(result.columns[0].name == "id") + #expect(result.columns[0].typeName == "INT") + #expect(result.rows.count == 1) + } + + @Test("FetchTables maps types correctly") + func fetchTablesMaps() async throws { + let stub = StubPluginDriver() + let adapter = PluginDriverAdapter(pluginDriver: stub) + + let tables = try await adapter.fetchTables(schema: nil) + #expect(tables.count == 1) + #expect(tables[0].name == "users") + #expect(tables[0].type == .table) + #expect(tables[0].rowCount == 42) + } + + @Test("FetchColumns maps with ordinal position") + func fetchColumnsMaps() async throws { + let stub = StubPluginDriver() + let adapter = PluginDriverAdapter(pluginDriver: stub) + + let columns = try await adapter.fetchColumns(table: "users", schema: nil) + #expect(columns.count == 1) + #expect(columns[0].name == "id") + #expect(columns[0].isPrimaryKey) + #expect(columns[0].ordinalPosition == 0) + } + + @Test("Connect and disconnect delegate to plugin driver") + func connectDisconnect() async throws { + let stub = StubPluginDriver() + let adapter = PluginDriverAdapter(pluginDriver: stub) + + try await adapter.connect() + #expect(stub.connectCalled) + + try await adapter.disconnect() + #expect(stub.disconnectCalled) + } + + @Test("Ping returns true on success") + func pingSuccess() async throws { + let stub = StubPluginDriver() + let adapter = PluginDriverAdapter(pluginDriver: stub) + + let alive = try await adapter.ping() + #expect(alive) + } + + @Test("FetchDatabases returns list") + func fetchDatabases() async throws { + let stub = StubPluginDriver() + let adapter = PluginDriverAdapter(pluginDriver: stub) + + let dbs = try await adapter.fetchDatabases() + #expect(dbs == ["db1", "db2"]) + } +} diff --git a/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift b/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift new file mode 100644 index 000000000..ba0542598 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift @@ -0,0 +1,63 @@ +import Testing +import Foundation +@testable import TableProModels + +@Suite("DatabaseType Tests") +struct DatabaseTypeTests { + @Test("Static constants have correct raw values") + func staticConstants() { + #expect(DatabaseType.mysql.rawValue == "mysql") + #expect(DatabaseType.postgresql.rawValue == "postgresql") + #expect(DatabaseType.sqlite.rawValue == "sqlite") + #expect(DatabaseType.redis.rawValue == "redis") + #expect(DatabaseType.mongodb.rawValue == "mongodb") + #expect(DatabaseType.cloudflareD1.rawValue == "cloudflared1") + } + + @Test("pluginTypeId maps multi-type databases") + func pluginTypeIdMapping() { + #expect(DatabaseType.mysql.pluginTypeId == "mysql") + #expect(DatabaseType.mariadb.pluginTypeId == "mysql") + #expect(DatabaseType.postgresql.pluginTypeId == "postgresql") + #expect(DatabaseType.redshift.pluginTypeId == "postgresql") + #expect(DatabaseType.sqlite.pluginTypeId == "sqlite") + } + + @Test("Unknown types pass through pluginTypeId") + func unknownTypePassthrough() { + let custom = DatabaseType(rawValue: "custom_db") + #expect(custom.pluginTypeId == "custom_db") + } + + @Test("Codable round-trip preserves value") + func codableRoundTrip() throws { + let original = DatabaseType.postgresql + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(DatabaseType.self, from: data) + #expect(decoded == original) + } + + @Test("Unknown type Codable round-trip") + func unknownCodableRoundTrip() throws { + let original = DatabaseType(rawValue: "future_db") + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(DatabaseType.self, from: data) + #expect(decoded == original) + #expect(decoded.rawValue == "future_db") + } + + @Test("allKnownTypes contains all expected types") + func allKnownTypesComplete() { + #expect(DatabaseType.allKnownTypes.count == 16) + #expect(DatabaseType.allKnownTypes.contains(.mysql)) + #expect(DatabaseType.allKnownTypes.contains(.bigquery)) + } + + @Test("Hashable conformance") + func hashableConformance() { + var set: Set = [.mysql, .postgresql, .mysql] + #expect(set.count == 2) + set.insert(DatabaseType(rawValue: "mysql")) + #expect(set.count == 2) + } +} diff --git a/Packages/TableProCore/Tests/TableProModelsTests/QueryResultMappingTests.swift b/Packages/TableProCore/Tests/TableProModelsTests/QueryResultMappingTests.swift new file mode 100644 index 000000000..35f0bb8a8 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProModelsTests/QueryResultMappingTests.swift @@ -0,0 +1,101 @@ +import Testing +import Foundation +@testable import TableProModels +@testable import TableProPluginKit + +@Suite("QueryResult Mapping Tests") +struct QueryResultMappingTests { + @Test("Maps PluginQueryResult to QueryResult") + func mapPluginQueryResult() { + let plugin = PluginQueryResult( + columns: ["id", "name", "email"], + columnTypeNames: ["INTEGER", "VARCHAR", "VARCHAR"], + rows: [["1", "Alice", "alice@test.com"], ["2", "Bob", nil]], + rowsAffected: 0, + executionTime: 0.042, + isTruncated: false, + statusMessage: "OK" + ) + + let result = QueryResult(from: plugin) + + #expect(result.columns.count == 3) + #expect(result.columns[0].name == "id") + #expect(result.columns[0].typeName == "INTEGER") + #expect(result.columns[0].ordinalPosition == 0) + #expect(result.columns[2].name == "email") + #expect(result.columns[2].ordinalPosition == 2) + #expect(result.rows.count == 2) + #expect(result.rows[1][2] == nil) + #expect(result.executionTime == 0.042) + #expect(result.statusMessage == "OK") + } + + @Test("Maps PluginTableInfo to TableInfo") + func mapPluginTableInfo() { + let tablePlugin = PluginTableInfo(name: "users", type: "TABLE", rowCount: 1000) + let table = TableInfo(from: tablePlugin) + #expect(table.name == "users") + #expect(table.type == .table) + #expect(table.rowCount == 1000) + + let viewPlugin = PluginTableInfo(name: "active_users", type: "VIEW") + let view = TableInfo(from: viewPlugin) + #expect(view.type == .view) + + let matViewPlugin = PluginTableInfo(name: "summary", type: "MATERIALIZED VIEW") + let matView = TableInfo(from: matViewPlugin) + #expect(matView.type == .materializedView) + } + + @Test("Maps PluginColumnInfo to ColumnInfo") + func mapPluginColumnInfo() { + let plugin = PluginColumnInfo( + name: "email", + dataType: "VARCHAR(255)", + isNullable: false, + isPrimaryKey: false, + defaultValue: nil, + comment: "User email" + ) + let col = ColumnInfo(from: plugin, ordinalPosition: 2) + #expect(col.name == "email") + #expect(col.typeName == "VARCHAR(255)") + #expect(col.isNullable == false) + #expect(col.comment == "User email") + #expect(col.ordinalPosition == 2) + } + + @Test("Maps PluginIndexInfo to IndexInfo") + func mapPluginIndexInfo() { + let plugin = PluginIndexInfo( + name: "idx_email", + columns: ["email"], + isUnique: true, + isPrimary: false, + type: "BTREE" + ) + let index = IndexInfo(from: plugin) + #expect(index.name == "idx_email") + #expect(index.columns == ["email"]) + #expect(index.isUnique) + #expect(!index.isPrimary) + } + + @Test("Maps PluginForeignKeyInfo to ForeignKeyInfo") + func mapPluginForeignKeyInfo() { + let plugin = PluginForeignKeyInfo( + name: "fk_user", + column: "user_id", + referencedTable: "users", + referencedColumn: "id", + onDelete: "CASCADE", + onUpdate: "NO ACTION" + ) + let fk = ForeignKeyInfo(from: plugin) + #expect(fk.name == "fk_user") + #expect(fk.column == "user_id") + #expect(fk.referencedTable == "users") + #expect(fk.onDelete == "CASCADE") + } +} diff --git a/Packages/TableProCore/Tests/TableProModelsTests/TableFilterTests.swift b/Packages/TableProCore/Tests/TableProModelsTests/TableFilterTests.swift new file mode 100644 index 000000000..469ab1e74 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProModelsTests/TableFilterTests.swift @@ -0,0 +1,92 @@ +import Testing +import Foundation +@testable import TableProModels + +@Suite("TableFilter Tests") +struct TableFilterTests { + @Test("Valid filter with value") + func validFilterWithValue() { + let filter = TableFilter(columnName: "name", filterOperator: .equal, value: "test") + #expect(filter.isValid) + } + + @Test("Invalid filter with empty column") + func invalidEmptyColumn() { + let filter = TableFilter(columnName: "", filterOperator: .equal, value: "test") + #expect(!filter.isValid) + } + + @Test("Invalid filter with empty value") + func invalidEmptyValue() { + let filter = TableFilter(columnName: "name", filterOperator: .equal, value: "") + #expect(!filter.isValid) + } + + @Test("isNull does not require value") + func isNullNoValue() { + let filter = TableFilter(columnName: "name", filterOperator: .isNull, value: "") + #expect(filter.isValid) + } + + @Test("isNotNull does not require value") + func isNotNullNoValue() { + let filter = TableFilter(columnName: "name", filterOperator: .isNotNull, value: "") + #expect(filter.isValid) + } + + @Test("Between requires both values") + func betweenRequiresBothValues() { + let incomplete = TableFilter( + columnName: "age", + filterOperator: .between, + value: "10", + secondValue: "" + ) + #expect(!incomplete.isValid) + + let complete = TableFilter( + columnName: "age", + filterOperator: .between, + value: "10", + secondValue: "20" + ) + #expect(complete.isValid) + } + + @Test("Raw SQL filter validation") + func rawSQLFilter() { + let valid = TableFilter( + columnName: TableFilter.rawSQLColumn, + rawSQL: "age > 10" + ) + #expect(valid.isValid) + + let invalid = TableFilter( + columnName: TableFilter.rawSQLColumn, + rawSQL: "" + ) + #expect(!invalid.isValid) + + let nilSQL = TableFilter( + columnName: TableFilter.rawSQLColumn, + rawSQL: nil + ) + #expect(!nilSQL.isValid) + } + + @Test("Codable round-trip") + func codableRoundTrip() throws { + let original = TableFilter( + columnName: "email", + filterOperator: .contains, + value: "test@example.com", + isEnabled: true + ) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(TableFilter.self, from: data) + #expect(decoded.columnName == original.columnName) + #expect(decoded.filterOperator == original.filterOperator) + #expect(decoded.value == original.value) + #expect(decoded.isEnabled == original.isEnabled) + } +} diff --git a/Packages/TableProCore/Tests/TableProQueryTests/FilterSQLGeneratorTests.swift b/Packages/TableProCore/Tests/TableProQueryTests/FilterSQLGeneratorTests.swift new file mode 100644 index 000000000..cc566b7d4 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProQueryTests/FilterSQLGeneratorTests.swift @@ -0,0 +1,126 @@ +import Testing +import Foundation +@testable import TableProQuery +@testable import TableProModels +@testable import TableProPluginKit + +@Suite("FilterSQLGenerator Tests") +struct FilterSQLGeneratorTests { + private var dialect: SQLDialectDescriptor { + SQLDialectDescriptor( + identifierQuote: "\"", + keywords: [], + functions: [], + dataTypes: [], + likeEscapeStyle: .explicit + ) + } + + @Test("Equal filter generates correct SQL") + func equalFilter() { + let generator = FilterSQLGenerator(dialect: dialect) + let filter = TableFilter(columnName: "name", filterOperator: .equal, value: "Alice") + let result = generator.generateWhereClause(from: [filter], logicMode: .and) + #expect(result == "WHERE \"name\" = 'Alice'") + } + + @Test("Numeric values are not quoted") + func numericValues() { + let generator = FilterSQLGenerator(dialect: dialect) + let filter = TableFilter(columnName: "age", filterOperator: .greaterThan, value: "25") + let result = generator.generateWhereClause(from: [filter], logicMode: .and) + #expect(result == "WHERE \"age\" > 25") + } + + @Test("IS NULL filter") + func isNullFilter() { + let generator = FilterSQLGenerator(dialect: dialect) + let filter = TableFilter(columnName: "email", filterOperator: .isNull) + let result = generator.generateWhereClause(from: [filter], logicMode: .and) + #expect(result == "WHERE \"email\" IS NULL") + } + + @Test("Multiple filters with AND") + func multipleFiltersAnd() { + let generator = FilterSQLGenerator(dialect: dialect) + let filters = [ + TableFilter(columnName: "age", filterOperator: .greaterThan, value: "18"), + TableFilter(columnName: "active", filterOperator: .equal, value: "1") + ] + let result = generator.generateWhereClause(from: filters, logicMode: .and) + #expect(result.contains("AND")) + #expect(result.hasPrefix("WHERE")) + } + + @Test("Multiple filters with OR") + func multipleFiltersOr() { + let generator = FilterSQLGenerator(dialect: dialect) + let filters = [ + TableFilter(columnName: "status", filterOperator: .equal, value: "active"), + TableFilter(columnName: "status", filterOperator: .equal, value: "pending") + ] + let result = generator.generateWhereClause(from: filters, logicMode: .or) + #expect(result.contains("OR")) + } + + @Test("Disabled filters are excluded") + func disabledFiltersExcluded() { + let generator = FilterSQLGenerator(dialect: dialect) + let filters = [ + TableFilter(columnName: "name", filterOperator: .equal, value: "test", isEnabled: false), + TableFilter(columnName: "age", filterOperator: .greaterThan, value: "10", isEnabled: true) + ] + let result = generator.generateWhereClause(from: filters, logicMode: .and) + #expect(!result.contains("name")) + #expect(result.contains("age")) + } + + @Test("Empty active filters returns empty string") + func emptyFilters() { + let generator = FilterSQLGenerator(dialect: dialect) + let result = generator.generateWhereClause(from: [], logicMode: .and) + #expect(result == "") + } + + @Test("BETWEEN filter") + func betweenFilter() { + let generator = FilterSQLGenerator(dialect: dialect) + let filter = TableFilter( + columnName: "price", + filterOperator: .between, + value: "10", + secondValue: "100" + ) + let result = generator.generateWhereClause(from: [filter], logicMode: .and) + #expect(result == "WHERE \"price\" BETWEEN 10 AND 100") + } + + @Test("CONTAINS filter uses LIKE with wildcards") + func containsFilter() { + let generator = FilterSQLGenerator(dialect: dialect) + let filter = TableFilter(columnName: "name", filterOperator: .contains, value: "test") + let result = generator.generateWhereClause(from: [filter], logicMode: .and) + #expect(result.contains("LIKE '%test%'")) + } + + @Test("Raw SQL filter passes through") + func rawSQLFilter() { + let generator = FilterSQLGenerator(dialect: dialect) + let filter = TableFilter( + columnName: TableFilter.rawSQLColumn, + rawSQL: "age > 10 AND active = 1" + ) + let result = generator.generateWhereClause(from: [filter], logicMode: .and) + #expect(result == "WHERE age > 10 AND active = 1") + } + + @Test("IN filter") + func inFilter() { + let generator = FilterSQLGenerator(dialect: dialect) + let filter = TableFilter(columnName: "status", filterOperator: .in, value: "active,pending,new") + let result = generator.generateWhereClause(from: [filter], logicMode: .and) + #expect(result.contains("IN")) + #expect(result.contains("'active'")) + #expect(result.contains("'pending'")) + } +} diff --git a/Packages/TableProCore/Tests/TableProQueryTests/TableQueryBuilderTests.swift b/Packages/TableProCore/Tests/TableProQueryTests/TableQueryBuilderTests.swift new file mode 100644 index 000000000..a1dfc3b05 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProQueryTests/TableQueryBuilderTests.swift @@ -0,0 +1,85 @@ +import Testing +import Foundation +@testable import TableProQuery +@testable import TableProModels +@testable import TableProPluginKit + +@Suite("TableQueryBuilder Tests") +struct TableQueryBuilderTests { + private var dialect: SQLDialectDescriptor { + SQLDialectDescriptor( + identifierQuote: "\"", + keywords: [], + functions: [], + dataTypes: [], + paginationStyle: .limit + ) + } + + @Test("Basic browse query") + func basicBrowse() { + let builder = TableQueryBuilder(dialect: dialect) + let query = builder.buildBrowseQuery(tableName: "users", limit: 100, offset: 0) + #expect(query == "SELECT * FROM \"users\" LIMIT 100 OFFSET 0") + } + + @Test("Browse query with sort") + func browseWithSort() { + let builder = TableQueryBuilder(dialect: dialect) + let sort = SortState(columns: [SortColumn(name: "name", ascending: true)]) + let query = builder.buildBrowseQuery(tableName: "users", sortState: sort, limit: 50, offset: 10) + #expect(query.contains("ORDER BY \"name\" ASC")) + #expect(query.contains("LIMIT 50 OFFSET 10")) + } + + @Test("Browse query with descending sort") + func browseWithDescSort() { + let builder = TableQueryBuilder(dialect: dialect) + let sort = SortState(columns: [SortColumn(name: "created_at", ascending: false)]) + let query = builder.buildBrowseQuery(tableName: "posts", sortState: sort, limit: 20, offset: 0) + #expect(query.contains("ORDER BY \"created_at\" DESC")) + } + + @Test("Offset-fetch pagination style") + func offsetFetchPagination() { + let offsetDialect = SQLDialectDescriptor( + identifierQuote: "\"", + keywords: [], + functions: [], + dataTypes: [], + paginationStyle: .offsetFetch, + offsetFetchOrderBy: "ORDER BY (SELECT NULL)" + ) + let builder = TableQueryBuilder(dialect: offsetDialect) + let query = builder.buildBrowseQuery(tableName: "users", limit: 50, offset: 100) + #expect(query.contains("OFFSET 100 ROWS FETCH NEXT 50 ROWS ONLY")) + } + + @Test("Filtered query generates WHERE clause") + func filteredQuery() { + let builder = TableQueryBuilder(dialect: dialect) + let filters = [TableFilter(columnName: "active", filterOperator: .equal, value: "1")] + let query = builder.buildFilteredQuery( + tableName: "users", + filters: filters, + limit: 100, + offset: 0 + ) + #expect(query.contains("WHERE")) + #expect(query.contains("\"active\"")) + } + + @Test("No dialect falls back to LIMIT pagination") + func noDialectFallback() { + let builder = TableQueryBuilder() + let query = builder.buildBrowseQuery(tableName: "test", limit: 10, offset: 5) + #expect(query.contains("LIMIT 10 OFFSET 5")) + } + + @Test("Table name with special characters is quoted") + func specialTableName() { + let builder = TableQueryBuilder(dialect: dialect) + let query = builder.buildBrowseQuery(tableName: "my table", limit: 10, offset: 0) + #expect(query.contains("\"my table\"")) + } +} diff --git a/docs/development/tablepro-core-design.md b/docs/development/tablepro-core-design.md new file mode 100644 index 000000000..711137bfc --- /dev/null +++ b/docs/development/tablepro-core-design.md @@ -0,0 +1,771 @@ +# TableProCore Package — Architecture Design + +> Status: DRAFT — for review before implementation + +## Overview + +`TableProCore` is a new cross-platform Swift Package that provides shared business logic for both TablePro (macOS) and TablePro Mobile (iOS). It is written from scratch with clean architecture — not extracted from the macOS codebase. + +**Principles:** +- Zero platform dependencies (Foundation + Swift stdlib only) +- Dependency injection — no `.shared` singletons +- Proper dependency direction: Core knows nothing about UI/platform +- Only abstract where implementations genuinely differ between platforms +- Keep PluginKit ↔ App type boundary (adapter pattern is intentional) + +## Package Structure + +``` +Packages/TableProCore/ +├── Package.swift +├── Sources/ +│ ├── TableProPluginKit/ ← plugin protocols + transfer types (ABI boundary) +│ ├── TableProModels/ ← pure value types, zero deps except PluginKit +│ ├── TableProDatabase/ ← connection management, driver adapter +│ └── TableProQuery/ ← query building, filtering, parsing +└── Tests/ + ├── TableProModelsTests/ + ├── TableProDatabaseTests/ + └── TableProQueryTests/ +``` + +Dependency graph: +``` +TableProQuery ──→ TableProModels ──→ TableProPluginKit + ↑ +TableProDatabase ──────┘ +``` + +No cycles. Each target only depends downward. + +--- + +## Module 1: TableProPluginKit + +Mostly migrated from existing `Plugins/TableProPluginKit/`. Changes: + +### Cleanup +- Remove `import SwiftUI` from `DriverPlugin.swift` — move `CompletionEntry` to a separate file that platforms can extend +- All types remain `Sendable` + `Codable` where applicable +- This is the **ABI boundary** — changes here require plugin recompilation + +### Key Types (unchanged) +```swift +// Protocols +public protocol TableProPlugin { ... } +public protocol DriverPlugin: TableProPlugin { ... } +public protocol PluginDatabaseDriver: AnyObject, Sendable { ... } +public protocol ExportFormatPlugin: TableProPlugin { ... } +public protocol ImportFormatPlugin: TableProPlugin { ... } + +// Transfer types (plugin → app) +public struct PluginQueryResult: Codable, Sendable { ... } +public struct PluginColumnInfo: Codable, Sendable { ... } +public struct PluginTableInfo: Codable, Sendable { ... } +public struct PluginIndexInfo: Codable, Sendable { ... } +public struct PluginForeignKeyInfo: Codable, Sendable { ... } +public struct PluginTableMetadata: Sendable { ... } +public struct PluginDatabaseMetadata: Sendable { ... } + +// Config +public struct DriverConnectionConfig: Sendable { ... } +public struct ConnectionField: Codable, Sendable { ... } +public struct SQLDialectDescriptor: Sendable { ... } +``` + +### CompletionEntry Change +```swift +// OLD (in DriverPlugin.swift, requires SwiftUI): +// public struct CompletionEntry { var icon: Image ... } + +// NEW: icon is a string identifier, platform resolves to Image/UIImage +public struct CompletionEntry: Sendable { + public let label: String + public let detail: String? + public let iconName: String // SF Symbol name — platform renders + public let kind: CompletionKind + + public enum CompletionKind: String, Sendable { + case keyword, function, table, column, schema, database, snippet + } +} +``` + +--- + +## Module 2: TableProModels + +Pure value types. No service calls, no platform types, no `@Observable`. + +### DatabaseType + +```swift +/// String-based struct for open extensibility. +/// All `switch` statements must include `default:`. +public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable { + public let rawValue: String + public init(rawValue: String) { self.rawValue = rawValue } + + // Known constants + public static let mysql = DatabaseType(rawValue: "mysql") + public static let mariadb = DatabaseType(rawValue: "mariadb") + public static let postgresql = DatabaseType(rawValue: "postgresql") + public static let sqlite = DatabaseType(rawValue: "sqlite") + public static let redis = DatabaseType(rawValue: "redis") + public static let mongodb = DatabaseType(rawValue: "mongodb") + public static let clickhouse = DatabaseType(rawValue: "clickhouse") + public static let mssql = DatabaseType(rawValue: "mssql") + public static let oracle = DatabaseType(rawValue: "oracle") + public static let duckdb = DatabaseType(rawValue: "duckdb") + public static let cassandra = DatabaseType(rawValue: "cassandra") + public static let redshift = DatabaseType(rawValue: "redshift") + public static let etcd = DatabaseType(rawValue: "etcd") + public static let cloudflareD1 = DatabaseType(rawValue: "cloudflared1") + public static let dynamodb = DatabaseType(rawValue: "dynamodb") + public static let bigquery = DatabaseType(rawValue: "bigquery") + + public static let allKnownTypes: [DatabaseType] = [ + .mysql, .mariadb, .postgresql, .sqlite, .redis, .mongodb, + .clickhouse, .mssql, .oracle, .duckdb, .cassandra, .redshift, + .etcd, .cloudflareD1, .dynamodb, .bigquery + ] + + /// Plugin type ID for plugin lookup. + /// Multi-type plugins: mariadb → "mysql", redshift → "postgresql" + public var pluginTypeId: String { + switch self { + case .mariadb: return DatabaseType.mysql.rawValue + case .redshift: return DatabaseType.postgresql.rawValue + default: return rawValue + } + } +} + +// NO iconImage, NO displayName, NO defaultPort, NO computed props calling services. +// UI metadata is queried via PluginMetadataProvider (see TableProDatabase module). +``` + +### DatabaseConnection + +```swift +public struct DatabaseConnection: Identifiable, Codable, Sendable { + public var id: UUID + public var name: String + public var type: DatabaseType + public var host: String + public var port: Int + public var username: String + public var database: String + public var colorTag: String? // color identifier, not Color/NSColor + public var isReadOnly: Bool + public var queryTimeoutSeconds: Int? + public var additionalFields: [String: String] + + // SSH + public var sshEnabled: Bool + public var sshConfiguration: SSHConfiguration? + + // SSL + public var sslEnabled: Bool + public var sslConfiguration: SSLConfiguration? + + // Grouping + public var groupId: UUID? + public var sortOrder: Int + + public init( + id: UUID = UUID(), + name: String = "", + type: DatabaseType = .mysql, + host: String = "127.0.0.1", + port: Int = 3306, + username: String = "", + database: String = "", + colorTag: String? = nil, + isReadOnly: Bool = false + ) { ... } + + // NO password field — passwords live in SecureStore + // NO NSImage, NO SwiftUI Color, NO @MainActor + // NO displayColor, NO iconImage computed properties +} + +public struct SSHConfiguration: Codable, Sendable { + public var host: String + public var port: Int + public var username: String + public var authMethod: SSHAuthMethod + public var privateKeyPath: String? + public var jumpHosts: [SSHJumpHost] + + public enum SSHAuthMethod: String, Codable, Sendable { + case password, publicKey, agent + } +} + +public struct SSLConfiguration: Codable, Sendable { + public var mode: SSLMode + public var caCertificatePath: String? + public var clientCertificatePath: String? + public var clientKeyPath: String? + + public enum SSLMode: String, Codable, Sendable { + case disable, require, verifyCa, verifyFull + } +} +``` + +### Query Result Types (app-side, mapped from Plugin types via adapter) + +```swift +public struct QueryResult: Sendable { + public let columns: [ColumnInfo] + public let rows: [[String?]] + public let rowsAffected: Int + public let executionTime: TimeInterval + public let isTruncated: Bool + public let statusMessage: String? +} + +public struct ColumnInfo: Sendable, Identifiable { + public let id: String // column name as ID + public let name: String + public let typeName: String + public let isPrimaryKey: Bool + public let isNullable: Bool + public let defaultValue: String? + public let comment: String? + public let characterMaxLength: Int? + public let ordinalPosition: Int +} + +public struct TableInfo: Hashable, Sendable, Identifiable { + public var id: String { name } + public let name: String + public let type: TableKind + public let rowCount: Int? + public let dataSize: Int? + public let comment: String? + + public enum TableKind: String, Sendable { + case table, view, materializedView, systemTable, sequence + } +} + +public struct IndexInfo: Sendable { ... } +public struct ForeignKeyInfo: Sendable { ... } + +public enum ConnectionStatus: Sendable { + case disconnected + case connecting + case connected + case error(String) +} + +public struct DatabaseError: Error, LocalizedError, Sendable { + public let code: Int? + public let message: String + public let sqlState: String? + public var errorDescription: String? { message } +} +``` + +### Filter Types + +```swift +public struct TableFilter: Identifiable, Codable, Sendable { + public var id: UUID + public var columnName: String + public var filterOperator: FilterOperator + public var value: String + public var secondValue: String // for BETWEEN + public var isEnabled: Bool + public var rawSQL: String? // for raw SQL filter mode + + public var isValid: Bool { ... } + + public static let rawSQLColumn = "__raw_sql__" +} + +public enum FilterOperator: String, Codable, Sendable { + case equal, notEqual + case greaterThan, greaterThanOrEqual + case lessThan, lessThanOrEqual + case like, notLike + case isNull, isNotNull + case `in`, notIn + case between + case contains, startsWith, endsWith +} + +public enum FilterLogicMode: String, Codable, Sendable { + case and = "AND" + case or = "OR" +} +``` + +### Tab / Pagination / Sort Types + +```swift +public struct PaginationState: Codable, Sendable { + public var pageSize: Int + public var currentPage: Int + public var totalRows: Int? + + public var currentOffset: Int { currentPage * pageSize } + public var hasNextPage: Bool { ... } + + public mutating func reset() { currentPage = 0; totalRows = nil } +} + +public struct SortState: Codable, Sendable { + public var columns: [SortColumn] + public var isSorting: Bool { !columns.isEmpty } + + public mutating func toggle(column: String) { ... } + public mutating func clear() { columns = [] } +} + +public struct SortColumn: Codable, Sendable { + public let name: String + public let ascending: Bool +} +``` + +### Schema Types + +```swift +public struct ColumnDefinition: Codable, Sendable { ... } +public struct IndexDefinition: Codable, Sendable { ... } +public struct ForeignKeyDefinition: Codable, Sendable { ... } +public struct CreateTableOptions: Codable, Sendable { ... } +``` + +--- + +## Module 3: TableProDatabase + +Connection management + driver adapter. Depends on Models + PluginKit. + +### Platform Injection Protocols + +Only 3 protocols — where macOS and iOS genuinely differ: + +```swift +/// Loads driver plugins. macOS: Bundle.load(). iOS: static registration. +public protocol PluginLoader: Sendable { + func availablePlugins() -> [any DriverPlugin] + func driverPlugin(for typeId: String) -> (any DriverPlugin)? +} + +/// Creates SSH tunnels. macOS: SSHTunnelManager. iOS: nil (not supported). +public protocol SSHProvider: Sendable { + func createTunnel( + config: SSHConfiguration, + remoteHost: String, + remotePort: Int + ) async throws -> SSHTunnel + func closeTunnel(for connectionId: UUID) async throws +} + +public struct SSHTunnel: Sendable { + public let localHost: String + public let localPort: Int +} + +/// Secure credential storage. Both platforms: Keychain (Security.framework). +/// Protocol exists because test mocking needs it. +public protocol SecureStore: Sendable { + func store(_ value: String, forKey key: String) throws + func retrieve(forKey key: String) throws -> String? + func delete(forKey key: String) throws +} +``` + +### DatabaseDriver Protocol (app-side) + +```swift +/// App-side driver protocol. PluginDriverAdapter bridges PluginDatabaseDriver → this. +public protocol DatabaseDriver: AnyObject, Sendable { + // Connection lifecycle + func connect() async throws + func disconnect() async throws + func ping() async throws -> Bool + + // Query execution + func execute(query: String) async throws -> QueryResult + func cancelCurrentQuery() async throws + + // Schema + func fetchTables(schema: String?) async throws -> [TableInfo] + func fetchColumns(table: String, schema: String?) async throws -> [ColumnInfo] + func fetchIndexes(table: String, schema: String?) async throws -> [IndexInfo] + func fetchForeignKeys(table: String, schema: String?) async throws -> [ForeignKeyInfo] + func fetchDatabases() async throws -> [String] + + // Database/Schema switching + func switchDatabase(to name: String) async throws + var supportsSchemas: Bool { get } + func switchSchema(to name: String) async throws + var currentSchema: String? { get } + + // Transactions + var supportsTransactions: Bool { get } + func beginTransaction() async throws + func commitTransaction() async throws + func rollbackTransaction() async throws + + // Metadata + var serverVersion: String? { get } +} +``` + +### PluginDriverAdapter + +Maps `PluginDatabaseDriver` → `DatabaseDriver`. Same role as current adapter but written clean. + +```swift +public final class PluginDriverAdapter: DatabaseDriver { + private let pluginDriver: any PluginDatabaseDriver + private let connection: DatabaseConnection + + public init(connection: DatabaseConnection, pluginDriver: any PluginDatabaseDriver) { + self.connection = connection + self.pluginDriver = pluginDriver + } + + public func execute(query: String) async throws -> QueryResult { + let pluginResult = try await pluginDriver.execute(query: query) + return QueryResult(from: pluginResult) // map Plugin types → App types + } + + public func fetchTables(schema: String?) async throws -> [TableInfo] { + let pluginTables = try await pluginDriver.fetchTables(schema: schema) + return pluginTables.map { TableInfo(from: $0) } + } + + // ... etc — clean mapping, no leaked internals +} + +// Clean mapping extensions +extension QueryResult { + init(from plugin: PluginQueryResult) { ... } +} + +extension TableInfo { + init(from plugin: PluginTableInfo) { ... } +} +``` + +### ConnectionManager + +Concrete class (not protocol — 95% shared logic). Platform differences injected. + +```swift +public final class ConnectionManager: @unchecked Sendable { + private let pluginLoader: PluginLoader + private let secureStore: SecureStore + private let sshProvider: SSHProvider? + + // State + private var sessions: [UUID: ConnectionSession] = [:] + + public init( + pluginLoader: PluginLoader, + secureStore: SecureStore, + sshProvider: SSHProvider? = nil + ) { + self.pluginLoader = pluginLoader + self.secureStore = secureStore + self.sshProvider = sshProvider + } + + /// Connect to a database. Returns session on success. + public func connect(_ connection: DatabaseConnection) async throws -> ConnectionSession { + // 1. Resolve password from SecureStore + let password = try secureStore.retrieve(forKey: connection.id.uuidString) + + // 2. Set up SSH tunnel if needed + var effectiveHost = connection.host + var effectivePort = connection.port + if connection.sshEnabled, let ssh = connection.sshConfiguration, let provider = sshProvider { + let tunnel = try await provider.createTunnel( + config: ssh, + remoteHost: connection.host, + remotePort: connection.port + ) + effectiveHost = tunnel.localHost + effectivePort = tunnel.localPort + } + + // 3. Create driver via plugin + guard let plugin = pluginLoader.driverPlugin(for: connection.type.pluginTypeId) else { + throw ConnectionError.pluginNotFound(connection.type.rawValue) + } + let config = DriverConnectionConfig( + host: effectiveHost, + port: effectivePort, + user: connection.username, + password: password ?? "", + database: connection.database, + additionalFields: connection.additionalFields + ) + let pluginDriver = plugin.createDriver(config: config) + + // 4. Connect + let driver = PluginDriverAdapter(connection: connection, pluginDriver: pluginDriver) + try await driver.connect() + + // 5. Create session + let session = ConnectionSession( + connectionId: connection.id, + driver: driver, + activeDatabase: connection.database, + status: .connected + ) + sessions[connection.id] = session + return session + } + + public func disconnect(_ connectionId: UUID) async throws { + guard let session = sessions[connectionId] else { return } + try await session.driver.disconnect() + if let sshProvider, session.connection.sshEnabled { + try await sshProvider.closeTunnel(for: connectionId) + } + sessions.removeValue(forKey: connectionId) + } + + public func session(for connectionId: UUID) -> ConnectionSession? { + sessions[connectionId] + } +} + +public enum ConnectionError: Error, LocalizedError { + case pluginNotFound(String) + case notConnected + case sshNotSupported + + public var errorDescription: String? { + switch self { + case .pluginNotFound(let type): return "No driver plugin for database type: \(type)" + case .notConnected: return "Not connected to database" + case .sshNotSupported: return "SSH tunneling is not available on this platform" + } + } +} +``` + +### ConnectionSession + +```swift +public struct ConnectionSession: Sendable { + public let connectionId: UUID + public let driver: any DatabaseDriver + public var activeDatabase: String + public var currentSchema: String? + public var status: ConnectionStatus + public var tables: [TableInfo] + + // NO UI state (isExpanded, selectedTable, etc.) + // NO @Observable — platform layer wraps this in observable if needed +} +``` + +### PluginMetadataProvider + +Replaces the current pattern where `DatabaseType` computed properties call `PluginMetadataRegistry.shared`. + +```swift +/// Provides metadata about database types from loaded plugins. +/// Replaces computed properties on DatabaseType that called singletons. +public final class PluginMetadataProvider: Sendable { + private let pluginLoader: PluginLoader + + public init(pluginLoader: PluginLoader) { + self.pluginLoader = pluginLoader + } + + public func displayName(for type: DatabaseType) -> String { + plugin(for: type)?.databaseDisplayName ?? type.rawValue.capitalized + } + + public func defaultPort(for type: DatabaseType) -> Int { + plugin(for: type)?.defaultPort ?? 3306 + } + + public func iconName(for type: DatabaseType) -> String { + plugin(for: type)?.iconName ?? "server.rack" + } + + public func supportsSSH(for type: DatabaseType) -> Bool { + plugin(for: type)?.supportsSSH ?? false + } + + public func supportsSSL(for type: DatabaseType) -> Bool { + plugin(for: type)?.supportsSSL ?? false + } + + public func sqlDialect(for type: DatabaseType) -> SQLDialectDescriptor? { + plugin(for: type)?.sqlDialect + } + + // ... other metadata queries + + private func plugin(for type: DatabaseType) -> (any DriverPlugin)? { + pluginLoader.driverPlugin(for: type.pluginTypeId) + } +} +``` + +--- + +## Module 4: TableProQuery + +Query building, filtering, SQL generation. Pure logic, depends on Models + PluginKit. + +### TableQueryBuilder + +```swift +public struct TableQueryBuilder: Sendable { + private let dialect: SQLDialectDescriptor? + private let pluginDriver: (any PluginDatabaseDriver)? + + public init(dialect: SQLDialectDescriptor? = nil, pluginDriver: (any PluginDatabaseDriver)? = nil) { + self.dialect = dialect + self.pluginDriver = pluginDriver + } + + /// Build a base SELECT query for browsing a table. + public func buildBrowseQuery( + tableName: String, + sortState: SortState = SortState(), + limit: Int, + offset: Int + ) -> String { ... } + + /// Build a filtered SELECT query. + public func buildFilteredQuery( + tableName: String, + filters: [TableFilter], + logicMode: FilterLogicMode = .and, + sortState: SortState = SortState(), + limit: Int, + offset: Int + ) -> String { ... } +} +``` + +### FilterSQLGenerator + +```swift +public struct FilterSQLGenerator: Sendable { + private let dialect: SQLDialectDescriptor + + public init(dialect: SQLDialectDescriptor) { + self.dialect = dialect + } + + public func generateWhereClause( + from filters: [TableFilter], + logicMode: FilterLogicMode + ) -> String { ... } +} +``` + +### SQLStatementGenerator + +```swift +/// Generates INSERT/UPDATE/DELETE statements from row changes. +public struct SQLStatementGenerator: Sendable { + private let dialect: SQLDialectDescriptor + + public func generateInsert(table: String, columns: [String], values: [String?]) -> String { ... } + public func generateUpdate(table: String, changes: [String: String?], where: [String: String]) -> String { ... } + public func generateDelete(table: String, where: [String: String]) -> String { ... } +} +``` + +### RowParser + +```swift +public protocol RowDataParser: Sendable { + func parse(text: String, columns: [String]) throws -> [[String?]] +} + +public struct TSVRowParser: RowDataParser { ... } +public struct CSVRowParser: RowDataParser { ... } +``` + +--- + +## Platform Integration + +### macOS (TablePro.xcodeproj) + +```swift +// macOS-specific implementations +final class BundlePluginLoader: PluginLoader { + func availablePlugins() -> [any DriverPlugin] { + // Load .tableplugin bundles from app bundle + user plugins directory + } +} + +final class SSHTunnelProvider: SSHProvider { + func createTunnel(...) async throws -> SSHTunnel { + // Existing SSHTunnelManager logic + } +} + +final class KeychainStore: SecureStore { + // Security.framework Keychain access +} + +// DatabaseType UI extensions (macOS only) +extension DatabaseType { + var iconImage: Image { ... } // SwiftUI Image from SF Symbol + var displayColor: Color { ... } // SwiftUI Color +} +``` + +### iOS (TableProMobile.xcodeproj) + +```swift +// iOS-specific implementations +final class StaticPluginLoader: PluginLoader { + func availablePlugins() -> [any DriverPlugin] { + // Return compiled-in plugins: MySQL, PostgreSQL, SQLite, Redis + [MySQLDriverPlugin(), PostgreSQLDriverPlugin(), SQLiteDriverPlugin(), RedisDriverPlugin()] + } +} + +// No SSHProvider — pass nil to ConnectionManager + +final class KeychainStore: SecureStore { + // Same Security.framework — works on iOS too +} + +// DatabaseType UI extensions (iOS only) +extension DatabaseType { + var iconImage: Image { ... } // Same SF Symbols, SwiftUI Image + var displayColor: Color { ... } +} +``` + +--- + +## Migration Strategy + +``` +Phase 1: Write TableProCore (new package, clean code) + Tests pass independently + +Phase 2: iOS app depends on TableProCore + Build iOS app + +Phase 3: macOS app gradually migrates to TableProCore + Module by module, behind feature flags if needed + Old code removed only after migration verified +``` + +macOS app continues working with existing code throughout. Zero risk. From 827295c61d9608945a07dbc3c417246431492235 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 31 Mar 2026 22:40:47 +0700 Subject: [PATCH 02/61] feat: add TableProMobile iOS app skeleton with core views --- TablePro/Core/Database/DatabaseManager.swift | 13 - .../TableProMobile.xcodeproj/project.pbxproj | 381 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + TableProMobile/TableProMobile/AppState.swift | 39 ++ .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 35 ++ .../Assets.xcassets/Contents.json | 6 + .../Platform/KeychainSecureStore.swift | 86 ++++ .../Platform/StaticPluginLoader.swift | 28 ++ .../TableProMobile/TableProMobileApp.swift | 20 + .../TableProMobile/Views/ConnectedView.swift | 63 +++ .../Views/ConnectionFormView.swift | 111 +++++ .../Views/ConnectionListView.swift | 115 ++++++ .../Views/DataBrowserView.swift | 153 +++++++ .../TableProMobile/Views/RowDetailView.swift | 61 +++ .../TableProMobile/Views/TableListView.swift | 75 ++++ 16 files changed, 1191 insertions(+), 13 deletions(-) create mode 100644 TableProMobile/TableProMobile.xcodeproj/project.pbxproj create mode 100644 TableProMobile/TableProMobile.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 TableProMobile/TableProMobile/AppState.swift create mode 100644 TableProMobile/TableProMobile/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 TableProMobile/TableProMobile/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 TableProMobile/TableProMobile/Assets.xcassets/Contents.json create mode 100644 TableProMobile/TableProMobile/Platform/KeychainSecureStore.swift create mode 100644 TableProMobile/TableProMobile/Platform/StaticPluginLoader.swift create mode 100644 TableProMobile/TableProMobile/TableProMobileApp.swift create mode 100644 TableProMobile/TableProMobile/Views/ConnectedView.swift create mode 100644 TableProMobile/TableProMobile/Views/ConnectionFormView.swift create mode 100644 TableProMobile/TableProMobile/Views/ConnectionListView.swift create mode 100644 TableProMobile/TableProMobile/Views/DataBrowserView.swift create mode 100644 TableProMobile/TableProMobile/Views/RowDetailView.swift create mode 100644 TableProMobile/TableProMobile/Views/TableListView.swift diff --git a/TablePro/Core/Database/DatabaseManager.swift b/TablePro/Core/Database/DatabaseManager.swift index d935f249a..9927cb44a 100644 --- a/TablePro/Core/Database/DatabaseManager.swift +++ b/TablePro/Core/Database/DatabaseManager.swift @@ -192,19 +192,6 @@ final class DatabaseManager { // Initialize schema for drivers that support schema switching if let schemaDriver = driver as? SchemaSwitchable { activeSessions[connection.id]?.currentSchema = schemaDriver.currentSchema - - // Restore user's last schema if different from default - if let savedSchema = AppSettingsStorage.shared.loadLastSchema(for: connection.id), - savedSchema != schemaDriver.currentSchema { - do { - try await schemaDriver.switchSchema(to: savedSchema) - activeSessions[connection.id]?.currentSchema = savedSchema - } catch { - Self.logger.warning( - "Failed to restore saved schema '\(savedSchema, privacy: .public)' for \(connection.id): \(error.localizedDescription, privacy: .public)" - ) - } - } } // Run post-connect actions declared by the plugin diff --git a/TableProMobile/TableProMobile.xcodeproj/project.pbxproj b/TableProMobile/TableProMobile.xcodeproj/project.pbxproj new file mode 100644 index 000000000..24e3a8623 --- /dev/null +++ b/TableProMobile/TableProMobile.xcodeproj/project.pbxproj @@ -0,0 +1,381 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 5AB9F3E92F7C1D03001F3337 /* TableProDatabase in Frameworks */ = {isa = PBXBuildFile; productRef = 5AB9F3E82F7C1D03001F3337 /* TableProDatabase */; }; + 5AB9F3EB2F7C1D03001F3337 /* TableProModels in Frameworks */ = {isa = PBXBuildFile; productRef = 5AB9F3EA2F7C1D03001F3337 /* TableProModels */; }; + 5AB9F3ED2F7C1D03001F3337 /* TableProPluginKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5AB9F3EC2F7C1D03001F3337 /* TableProPluginKit */; }; + 5AB9F3EF2F7C1D03001F3337 /* TableProQuery in Frameworks */ = {isa = PBXBuildFile; productRef = 5AB9F3EE2F7C1D03001F3337 /* TableProQuery */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 5AB9F3D92F7C1C12001F3337 /* TableProMobile.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TableProMobile.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 5AB9F3DB2F7C1C12001F3337 /* TableProMobile */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = TableProMobile; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 5AB9F3D62F7C1C12001F3337 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 5AB9F3EF2F7C1D03001F3337 /* TableProQuery in Frameworks */, + 5AB9F3E92F7C1D03001F3337 /* TableProDatabase in Frameworks */, + 5AB9F3ED2F7C1D03001F3337 /* TableProPluginKit in Frameworks */, + 5AB9F3EB2F7C1D03001F3337 /* TableProModels in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 5AB9F3D02F7C1C12001F3337 = { + isa = PBXGroup; + children = ( + 5AB9F3DB2F7C1C12001F3337 /* TableProMobile */, + 5AB9F3DA2F7C1C12001F3337 /* Products */, + ); + sourceTree = ""; + }; + 5AB9F3DA2F7C1C12001F3337 /* Products */ = { + isa = PBXGroup; + children = ( + 5AB9F3D92F7C1C12001F3337 /* TableProMobile.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 5AB9F3D82F7C1C12001F3337 /* TableProMobile */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5AB9F3E42F7C1C13001F3337 /* Build configuration list for PBXNativeTarget "TableProMobile" */; + buildPhases = ( + 5AB9F3D52F7C1C12001F3337 /* Sources */, + 5AB9F3D62F7C1C12001F3337 /* Frameworks */, + 5AB9F3D72F7C1C12001F3337 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 5AB9F3DB2F7C1C12001F3337 /* TableProMobile */, + ); + name = TableProMobile; + packageProductDependencies = ( + 5AB9F3E82F7C1D03001F3337 /* TableProDatabase */, + 5AB9F3EA2F7C1D03001F3337 /* TableProModels */, + 5AB9F3EC2F7C1D03001F3337 /* TableProPluginKit */, + 5AB9F3EE2F7C1D03001F3337 /* TableProQuery */, + ); + productName = TableProMobile; + productReference = 5AB9F3D92F7C1C12001F3337 /* TableProMobile.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 5AB9F3D12F7C1C12001F3337 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2640; + LastUpgradeCheck = 2640; + TargetAttributes = { + 5AB9F3D82F7C1C12001F3337 = { + CreatedOnToolsVersion = 26.4; + }; + }; + }; + buildConfigurationList = 5AB9F3D42F7C1C12001F3337 /* Build configuration list for PBXProject "TableProMobile" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 5AB9F3D02F7C1C12001F3337; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 5AB9F3E72F7C1D03001F3337 /* XCLocalSwiftPackageReference "../Packages/TableProCore" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 5AB9F3DA2F7C1C12001F3337 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 5AB9F3D82F7C1C12001F3337 /* TableProMobile */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 5AB9F3D72F7C1C12001F3337 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 5AB9F3D52F7C1C12001F3337 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 5AB9F3E22F7C1C13001F3337 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = D7HJ5TFYCU; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.4; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 5AB9F3E32F7C1C13001F3337 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = D7HJ5TFYCU; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.4; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 5AB9F3E52F7C1C13001F3337 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = D7HJ5TFYCU; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.TableProMobile; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 5AB9F3E62F7C1C13001F3337 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = D7HJ5TFYCU; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.TableProMobile; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 5AB9F3D42F7C1C12001F3337 /* Build configuration list for PBXProject "TableProMobile" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5AB9F3E22F7C1C13001F3337 /* Debug */, + 5AB9F3E32F7C1C13001F3337 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 5AB9F3E42F7C1C13001F3337 /* Build configuration list for PBXNativeTarget "TableProMobile" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5AB9F3E52F7C1C13001F3337 /* Debug */, + 5AB9F3E62F7C1C13001F3337 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 5AB9F3E72F7C1D03001F3337 /* XCLocalSwiftPackageReference "../Packages/TableProCore" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../Packages/TableProCore; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 5AB9F3E82F7C1D03001F3337 /* TableProDatabase */ = { + isa = XCSwiftPackageProductDependency; + productName = TableProDatabase; + }; + 5AB9F3EA2F7C1D03001F3337 /* TableProModels */ = { + isa = XCSwiftPackageProductDependency; + productName = TableProModels; + }; + 5AB9F3EC2F7C1D03001F3337 /* TableProPluginKit */ = { + isa = XCSwiftPackageProductDependency; + productName = TableProPluginKit; + }; + 5AB9F3EE2F7C1D03001F3337 /* TableProQuery */ = { + isa = XCSwiftPackageProductDependency; + productName = TableProQuery; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 5AB9F3D12F7C1C12001F3337 /* Project object */; +} diff --git a/TableProMobile/TableProMobile.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/TableProMobile/TableProMobile.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/TableProMobile/TableProMobile.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/TableProMobile/TableProMobile/AppState.swift b/TableProMobile/TableProMobile/AppState.swift new file mode 100644 index 000000000..09e33eaaf --- /dev/null +++ b/TableProMobile/TableProMobile/AppState.swift @@ -0,0 +1,39 @@ +// +// AppState.swift +// TableProMobile +// + +import Foundation +import Observation +import TableProDatabase +import TableProModels + +@MainActor @Observable +final class AppState { + var connections: [DatabaseConnection] = [] + let connectionManager: ConnectionManager + + init() { + let pluginLoader = StaticPluginLoader() + let secureStore = KeychainSecureStore() + self.connectionManager = ConnectionManager( + pluginLoader: pluginLoader, + secureStore: secureStore + ) + + loadSampleConnections() + } + + private func loadSampleConnections() { + // TODO: Load from persistent storage / iCloud sync + // For now, empty — user adds connections manually + } + + func addConnection(_ connection: DatabaseConnection) { + connections.append(connection) + } + + func removeConnection(_ connection: DatabaseConnection) { + connections.removeAll { $0.id == connection.id } + } +} diff --git a/TableProMobile/TableProMobile/Assets.xcassets/AccentColor.colorset/Contents.json b/TableProMobile/TableProMobile/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/TableProMobile/TableProMobile/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TableProMobile/TableProMobile/Assets.xcassets/AppIcon.appiconset/Contents.json b/TableProMobile/TableProMobile/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..230588010 --- /dev/null +++ b/TableProMobile/TableProMobile/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TableProMobile/TableProMobile/Assets.xcassets/Contents.json b/TableProMobile/TableProMobile/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/TableProMobile/TableProMobile/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TableProMobile/TableProMobile/Platform/KeychainSecureStore.swift b/TableProMobile/TableProMobile/Platform/KeychainSecureStore.swift new file mode 100644 index 000000000..7bbb022f5 --- /dev/null +++ b/TableProMobile/TableProMobile/Platform/KeychainSecureStore.swift @@ -0,0 +1,86 @@ +// +// KeychainSecureStore.swift +// TableProMobile +// +// iOS Keychain implementation for SecureStore protocol. +// Uses Security.framework (works on both macOS and iOS). +// + +import Foundation +import Security +import TableProDatabase + +final class KeychainSecureStore: SecureStore { + private let serviceName = "com.TablePro.Mobile" + + func store(_ value: String, forKey key: String) throws { + guard let data = value.data(using: .utf8) else { return } + + // Delete existing item first + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: key, + ] + SecItemDelete(deleteQuery as CFDictionary) + + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: key, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, + ] + let status = SecItemAdd(addQuery as CFDictionary, nil) + if status != errSecSuccess { + throw KeychainError.storeFailed(status) + } + } + + func retrieve(forKey key: String) throws -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + if status == errSecItemNotFound { return nil } + if status != errSecSuccess { + throw KeychainError.retrieveFailed(status) + } + + guard let data = result as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + func delete(forKey key: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: key, + ] + let status = SecItemDelete(query as CFDictionary) + if status != errSecSuccess && status != errSecItemNotFound { + throw KeychainError.deleteFailed(status) + } + } +} + +enum KeychainError: Error, LocalizedError { + case storeFailed(OSStatus) + case retrieveFailed(OSStatus) + case deleteFailed(OSStatus) + + var errorDescription: String? { + switch self { + case .storeFailed(let status): return "Keychain store failed: \(status)" + case .retrieveFailed(let status): return "Keychain retrieve failed: \(status)" + case .deleteFailed(let status): return "Keychain delete failed: \(status)" + } + } +} diff --git a/TableProMobile/TableProMobile/Platform/StaticPluginLoader.swift b/TableProMobile/TableProMobile/Platform/StaticPluginLoader.swift new file mode 100644 index 000000000..625dcf6de --- /dev/null +++ b/TableProMobile/TableProMobile/Platform/StaticPluginLoader.swift @@ -0,0 +1,28 @@ +// +// StaticPluginLoader.swift +// TableProMobile +// +// iOS plugin loader — returns compiled-in drivers. +// macOS uses BundlePluginLoader (runtime .tableplugin loading). +// + +import Foundation +import TableProDatabase +@preconcurrency import TableProPluginKit + +final class StaticPluginLoader: PluginLoader, Sendable { + nonisolated(unsafe) private let plugins: [any DriverPlugin] + + init() { + // TODO: Register compiled-in plugins when C libs are cross-compiled for iOS + plugins = [] + } + + func availablePlugins() -> [any DriverPlugin] { + plugins + } + + func driverPlugin(for typeId: String) -> (any DriverPlugin)? { + plugins.first { type(of: $0).databaseTypeId == typeId } + } +} diff --git a/TableProMobile/TableProMobile/TableProMobileApp.swift b/TableProMobile/TableProMobile/TableProMobileApp.swift new file mode 100644 index 000000000..de0d89a09 --- /dev/null +++ b/TableProMobile/TableProMobile/TableProMobileApp.swift @@ -0,0 +1,20 @@ +// +// TableProMobileApp.swift +// TableProMobile +// + +import SwiftUI +import TableProDatabase +import TableProModels + +@main +struct TableProMobileApp: App { + @State private var appState = AppState() + + var body: some Scene { + WindowGroup { + ConnectionListView() + .environment(appState) + } + } +} diff --git a/TableProMobile/TableProMobile/Views/ConnectedView.swift b/TableProMobile/TableProMobile/Views/ConnectedView.swift new file mode 100644 index 000000000..92b84e5cd --- /dev/null +++ b/TableProMobile/TableProMobile/Views/ConnectedView.swift @@ -0,0 +1,63 @@ +// +// ConnectedView.swift +// TableProMobile +// +// Wrapper that connects to the database, then shows TableListView. +// + +import SwiftUI +import TableProDatabase +import TableProModels + +struct ConnectedView: View { + @Environment(AppState.self) private var appState + let connection: DatabaseConnection + + @State private var session: ConnectionSession? + @State private var tables: [TableInfo] = [] + @State private var isConnecting = true + @State private var errorMessage: String? + + var body: some View { + Group { + if isConnecting { + ProgressView("Connecting to \(connection.name)...") + } else if let errorMessage { + ContentUnavailableView { + Label("Connection Failed", systemImage: "exclamationmark.triangle") + } description: { + Text(errorMessage) + } actions: { + Button("Retry") { + Task { await connect() } + } + .buttonStyle(.borderedProminent) + } + } else { + TableListView( + connection: connection, + tables: tables, + session: session + ) + } + } + .navigationTitle(connection.name.isEmpty ? connection.host : connection.name) + .navigationBarTitleDisplayMode(.inline) + .task { await connect() } + } + + private func connect() async { + isConnecting = true + errorMessage = nil + + do { + let session = try await appState.connectionManager.connect(connection) + self.session = session + self.tables = try await session.driver.fetchTables(schema: nil) + isConnecting = false + } catch { + errorMessage = error.localizedDescription + isConnecting = false + } + } +} diff --git a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift new file mode 100644 index 000000000..df7226e9c --- /dev/null +++ b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift @@ -0,0 +1,111 @@ +// +// ConnectionFormView.swift +// TableProMobile +// + +import SwiftUI +import TableProModels + +struct ConnectionFormView: View { + @Environment(\.dismiss) private var dismiss + + @State private var name = "" + @State private var type: DatabaseType = .mysql + @State private var host = "127.0.0.1" + @State private var port = "3306" + @State private var username = "" + @State private var password = "" + @State private var database = "" + @State private var sslEnabled = false + + var onSave: (DatabaseConnection) -> Void + + private let databaseTypes: [(DatabaseType, String)] = [ + (.mysql, "MySQL"), + (.postgresql, "PostgreSQL"), + (.sqlite, "SQLite"), + (.redis, "Redis"), + ] + + var body: some View { + NavigationStack { + Form { + Section("Connection") { + TextField("Name", text: $name) + .textInputAutocapitalization(.never) + + Picker("Database Type", selection: $type) { + ForEach(databaseTypes, id: \.0.rawValue) { dbType, label in + Text(label).tag(dbType) + } + } + .onChange(of: type) { _, newType in + updateDefaultPort(for: newType) + } + } + + if type != .sqlite { + Section("Server") { + TextField("Host", text: $host) + .textInputAutocapitalization(.never) + .keyboardType(.URL) + + TextField("Port", text: $port) + .keyboardType(.numberPad) + + TextField("Username", text: $username) + .textInputAutocapitalization(.never) + + SecureField("Password", text: $password) + } + } + + Section("Database") { + TextField(type == .sqlite ? "File Path" : "Database Name", text: $database) + .textInputAutocapitalization(.never) + } + + if type != .sqlite && type != .redis { + Section { + Toggle("SSL", isOn: $sslEnabled) + } + } + } + .navigationTitle("New Connection") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { save() } + .disabled(host.isEmpty && type != .sqlite) + } + } + } + } + + private func updateDefaultPort(for type: DatabaseType) { + switch type { + case .mysql, .mariadb: port = "3306" + case .postgresql: port = "5432" + case .redis: port = "6379" + case .sqlite: port = "" + default: port = "3306" + } + } + + private func save() { + let connection = DatabaseConnection( + name: name.isEmpty ? host : name, + type: type, + host: host, + port: Int(port) ?? 3306, + username: username, + database: database, + sslEnabled: sslEnabled + ) + // TODO: Store password in KeychainSecureStore + onSave(connection) + } +} diff --git a/TableProMobile/TableProMobile/Views/ConnectionListView.swift b/TableProMobile/TableProMobile/Views/ConnectionListView.swift new file mode 100644 index 000000000..fe60405f8 --- /dev/null +++ b/TableProMobile/TableProMobile/Views/ConnectionListView.swift @@ -0,0 +1,115 @@ +// +// ConnectionListView.swift +// TableProMobile +// + +import SwiftUI +import TableProModels + +struct ConnectionListView: View { + @Environment(AppState.self) private var appState + @State private var showingAddConnection = false + + var body: some View { + NavigationStack { + Group { + if appState.connections.isEmpty { + emptyState + } else { + connectionList + } + } + .navigationTitle("Connections") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + showingAddConnection = true + } label: { + Image(systemName: "plus") + } + } + } + .sheet(isPresented: $showingAddConnection) { + ConnectionFormView { connection in + appState.addConnection(connection) + showingAddConnection = false + } + } + } + } + + private var emptyState: some View { + ContentUnavailableView { + Label("No Connections", systemImage: "server.rack") + } description: { + Text("Add a database connection to get started.") + } actions: { + Button("Add Connection") { + showingAddConnection = true + } + .buttonStyle(.borderedProminent) + } + } + + private var connectionList: some View { + List { + ForEach(appState.connections) { connection in + NavigationLink(value: connection) { + ConnectionRow(connection: connection) + } + } + .onDelete { indexSet in + for index in indexSet { + appState.removeConnection(appState.connections[index]) + } + } + } + .navigationDestination(for: DatabaseConnection.self) { connection in + ConnectedView(connection: connection) + } + } +} + +struct ConnectionRow: View { + let connection: DatabaseConnection + + var body: some View { + HStack(spacing: 12) { + Image(systemName: iconName(for: connection.type)) + .font(.title2) + .foregroundStyle(.secondary) + .frame(width: 32) + + VStack(alignment: .leading, spacing: 2) { + Text(connection.name.isEmpty ? connection.host : connection.name) + .font(.body) + .fontWeight(.medium) + + Text("\(connection.type.rawValue) — \(connection.host):\(connection.port)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } + + private func iconName(for type: DatabaseType) -> String { + switch type { + case .mysql, .mariadb: return "cylinder" + case .postgresql, .redshift: return "elephant" + case .sqlite: return "doc" + case .redis: return "key" + default: return "server.rack" + } + } +} + +extension DatabaseConnection: @retroactive Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + public static func == (lhs: DatabaseConnection, rhs: DatabaseConnection) -> Bool { + lhs.id == rhs.id + } +} diff --git a/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/TableProMobile/TableProMobile/Views/DataBrowserView.swift new file mode 100644 index 000000000..093327361 --- /dev/null +++ b/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -0,0 +1,153 @@ +// +// DataBrowserView.swift +// TableProMobile +// + +import SwiftUI +import TableProDatabase +import TableProModels +import TableProQuery + +struct DataBrowserView: View { + let connection: DatabaseConnection + let table: TableInfo + let session: ConnectionSession? + + @State private var columns: [ColumnInfo] = [] + @State private var rows: [[String?]] = [] + @State private var isLoading = true + @State private var errorMessage: String? + @State private var selectedRow: IdentifiableRow? + @State private var pagination = PaginationState(pageSize: 100, currentPage: 0) + + var body: some View { + Group { + if isLoading { + ProgressView("Loading data...") + } else if let errorMessage { + ContentUnavailableView { + Label("Query Failed", systemImage: "exclamationmark.triangle") + } description: { + Text(errorMessage) + } actions: { + Button("Retry") { + Task { await loadData() } + } + .buttonStyle(.borderedProminent) + } + } else if rows.isEmpty { + ContentUnavailableView( + "No Data", + systemImage: "tray", + description: Text("This table is empty.") + ) + } else { + dataList + } + } + .navigationTitle(table.name) + .navigationBarTitleDisplayMode(.inline) + .task { await loadData() } + .sheet(item: $selectedRow) { row in + RowDetailView(columns: columns, row: row.values) + } + } + + private var dataList: some View { + List { + ForEach(Array(rows.enumerated()), id: \.offset) { index, row in + Button { + selectedRow = IdentifiableRow(values: row) + } label: { + RowSummaryView(columns: columns, row: row) + } + .foregroundStyle(.primary) + } + + if rows.count >= pagination.pageSize { + Button { + Task { await loadNextPage() } + } label: { + HStack { + Spacer() + Text("Load More") + .foregroundStyle(.blue) + Spacer() + } + } + } + } + .listStyle(.plain) + } + + private func loadData() async { + guard let session else { + errorMessage = "Not connected" + isLoading = false + return + } + + isLoading = true + errorMessage = nil + + do { + let query = "SELECT * FROM \(table.name) LIMIT \(pagination.pageSize) OFFSET \(pagination.currentOffset)" + let result = try await session.driver.execute(query: query) + self.columns = result.columns + self.rows = result.rows + isLoading = false + } catch { + errorMessage = error.localizedDescription + isLoading = false + } + } + + private func loadNextPage() async { + guard let session else { return } + + pagination.currentPage += 1 + do { + let query = "SELECT * FROM \(table.name) LIMIT \(pagination.pageSize) OFFSET \(pagination.currentOffset)" + let result = try await session.driver.execute(query: query) + rows.append(contentsOf: result.rows) + } catch { + pagination.currentPage -= 1 + } + } +} + +struct IdentifiableRow: Identifiable { + let id = UUID() + let values: [String?] +} + +struct RowSummaryView: View { + let columns: [ColumnInfo] + let row: [String?] + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + // Show first 3 columns as preview + ForEach(Array(zip(columns.prefix(3), row.prefix(3))), id: \.0.name) { col, value in + HStack(spacing: 6) { + Text(col.name) + .font(.caption) + .foregroundStyle(.secondary) + .frame(width: 80, alignment: .trailing) + + Text(value ?? "NULL") + .font(.body) + .foregroundStyle(value == nil ? .secondary : .primary) + .lineLimit(1) + } + } + + if columns.count > 3 { + Text("+\(columns.count - 3) more columns") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + .padding(.vertical, 4) + } +} diff --git a/TableProMobile/TableProMobile/Views/RowDetailView.swift b/TableProMobile/TableProMobile/Views/RowDetailView.swift new file mode 100644 index 000000000..dc97036f2 --- /dev/null +++ b/TableProMobile/TableProMobile/Views/RowDetailView.swift @@ -0,0 +1,61 @@ +// +// RowDetailView.swift +// TableProMobile +// + +import SwiftUI +import TableProModels + +struct RowDetailView: View { + @Environment(\.dismiss) private var dismiss + let columns: [ColumnInfo] + let row: [String?] + + var body: some View { + NavigationStack { + List { + ForEach(Array(zip(columns, row)), id: \.0.name) { column, value in + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(column.name) + .font(.caption) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + + Spacer() + + Text(column.typeName) + .font(.caption2) + .foregroundStyle(.tertiary) + + if column.isPrimaryKey { + Image(systemName: "key.fill") + .font(.caption2) + .foregroundStyle(.orange) + } + } + + if let value { + Text(value) + .font(.body) + .textSelection(.enabled) + } else { + Text("NULL") + .font(.body) + .foregroundStyle(.secondary) + .italic() + } + } + .padding(.vertical, 4) + } + } + .navigationTitle("Row Detail") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } + } +} diff --git a/TableProMobile/TableProMobile/Views/TableListView.swift b/TableProMobile/TableProMobile/Views/TableListView.swift new file mode 100644 index 000000000..8736212ed --- /dev/null +++ b/TableProMobile/TableProMobile/Views/TableListView.swift @@ -0,0 +1,75 @@ +// +// TableListView.swift +// TableProMobile +// + +import SwiftUI +import TableProDatabase +import TableProModels + +struct TableListView: View { + let connection: DatabaseConnection + let tables: [TableInfo] + let session: ConnectionSession? + + @State private var searchText = "" + + private var filteredTables: [TableInfo] { + if searchText.isEmpty { return tables } + return tables.filter { $0.name.localizedCaseInsensitiveContains(searchText) } + } + + var body: some View { + List { + ForEach(filteredTables) { table in + NavigationLink(value: table) { + TableRow(table: table) + } + } + } + .searchable(text: $searchText, prompt: "Search tables") + .navigationTitle("Tables") + .navigationDestination(for: TableInfo.self) { table in + DataBrowserView( + connection: connection, + table: table, + session: session + ) + } + .overlay { + if filteredTables.isEmpty && !searchText.isEmpty { + ContentUnavailableView.search(text: searchText) + } else if tables.isEmpty { + ContentUnavailableView( + "No Tables", + systemImage: "tablecells", + description: Text("This database has no tables.") + ) + } + } + } +} + +struct TableRow: View { + let table: TableInfo + + var body: some View { + HStack { + Image(systemName: table.type == .view ? "eye" : "tablecells") + .foregroundStyle(.secondary) + .frame(width: 24) + + VStack(alignment: .leading, spacing: 2) { + Text(table.name) + .font(.body) + + if let rowCount = table.rowCount { + Text("\(rowCount) rows") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } +} + From f52d62a76f5998e1132be0425163ee270e18b02d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 31 Mar 2026 23:18:22 +0700 Subject: [PATCH 03/61] refactor: remove plugin system from TableProDatabase, add DriverFactory protocol --- Packages/TableProCore/Package.swift | 4 +- .../TableProDatabase/ConnectionError.swift | 6 +- .../TableProDatabase/ConnectionManager.swift | 39 ++--- .../PluginDriverAdapter.swift | 102 ------------ .../PluginMetadataProvider.swift | 64 -------- .../Protocols/DriverFactory.swift | 9 ++ .../Protocols/PluginLoader.swift | 7 - .../TableProQuery/TableQueryBuilder.swift | 35 ++++- .../ConnectionManagerTests.swift | 145 +++++++++++------- .../PluginDriverAdapterTests.swift | 120 --------------- 10 files changed, 142 insertions(+), 389 deletions(-) delete mode 100644 Packages/TableProCore/Sources/TableProDatabase/PluginDriverAdapter.swift delete mode 100644 Packages/TableProCore/Sources/TableProDatabase/PluginMetadataProvider.swift create mode 100644 Packages/TableProCore/Sources/TableProDatabase/Protocols/DriverFactory.swift delete mode 100644 Packages/TableProCore/Sources/TableProDatabase/Protocols/PluginLoader.swift delete mode 100644 Packages/TableProCore/Tests/TableProDatabaseTests/PluginDriverAdapterTests.swift diff --git a/Packages/TableProCore/Package.swift b/Packages/TableProCore/Package.swift index e894dd359..9cd63b690 100644 --- a/Packages/TableProCore/Package.swift +++ b/Packages/TableProCore/Package.swift @@ -27,7 +27,7 @@ let package = Package( ), .target( name: "TableProDatabase", - dependencies: ["TableProModels", "TableProPluginKit"], + dependencies: ["TableProModels"], path: "Sources/TableProDatabase" ), .target( @@ -42,7 +42,7 @@ let package = Package( ), .testTarget( name: "TableProDatabaseTests", - dependencies: ["TableProDatabase", "TableProModels", "TableProPluginKit"], + dependencies: ["TableProDatabase", "TableProModels"], path: "Tests/TableProDatabaseTests" ), .testTarget( diff --git a/Packages/TableProCore/Sources/TableProDatabase/ConnectionError.swift b/Packages/TableProCore/Sources/TableProDatabase/ConnectionError.swift index ffb50f96a..32a3292da 100644 --- a/Packages/TableProCore/Sources/TableProDatabase/ConnectionError.swift +++ b/Packages/TableProCore/Sources/TableProDatabase/ConnectionError.swift @@ -1,14 +1,14 @@ import Foundation public enum ConnectionError: Error, LocalizedError { - case pluginNotFound(String) + case driverNotFound(String) case notConnected case sshNotSupported public var errorDescription: String? { switch self { - case .pluginNotFound(let type): - return "No driver plugin for database type: \(type)" + case .driverNotFound(let type): + return "No driver available for database type: \(type)" case .notConnected: return "Not connected to database" case .sshNotSupported: diff --git a/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift b/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift index 08a32a2e4..9bba76807 100644 --- a/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift +++ b/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift @@ -1,9 +1,8 @@ import Foundation import TableProModels -import TableProPluginKit public final class ConnectionManager: @unchecked Sendable { - private let pluginLoader: PluginLoader + private let driverFactory: DriverFactory private let secureStore: SecureStore private let sshProvider: SSHProvider? @@ -11,11 +10,11 @@ public final class ConnectionManager: @unchecked Sendable { private var sessions: [UUID: ConnectionSession] = [:] public init( - pluginLoader: PluginLoader, + driverFactory: DriverFactory, secureStore: SecureStore, sshProvider: SSHProvider? = nil ) { - self.pluginLoader = pluginLoader + self.driverFactory = driverFactory self.secureStore = secureStore self.sshProvider = sshProvider } @@ -39,21 +38,11 @@ public final class ConnectionManager: @unchecked Sendable { } do { - guard let plugin = pluginLoader.driverPlugin(for: connection.type.pluginTypeId) else { - throw ConnectionError.pluginNotFound(connection.type.rawValue) - } - - let config = DriverConnectionConfig( - host: effectiveHost, - port: effectivePort, - username: connection.username, - password: password ?? "", - database: connection.database, - additionalFields: connection.additionalFields - ) - let pluginDriver = plugin.createDriver(config: config) + var effectiveConnection = connection + effectiveConnection.host = effectiveHost + effectiveConnection.port = effectivePort - let driver = PluginDriverAdapter(pluginDriver: pluginDriver) + let driver = try driverFactory.createDriver(for: effectiveConnection, password: password) try await driver.connect() let session = ConnectionSession( @@ -74,10 +63,8 @@ public final class ConnectionManager: @unchecked Sendable { public func disconnect(_ connectionId: UUID) async { let session = removeSession(for: connectionId) - guard let session else { return } try? await session.driver.disconnect() - if let sshProvider { try? await sshProvider.closeTunnel(for: connectionId) } @@ -99,6 +86,12 @@ public final class ConnectionManager: @unchecked Sendable { updateSession(connectionId) { $0.activeDatabase = database } } + public func session(for connectionId: UUID) -> ConnectionSession? { + lock.lock() + defer { lock.unlock() } + return sessions[connectionId] + } + private func storeSession(_ session: ConnectionSession, for id: UUID) { lock.lock() sessions[id] = session @@ -111,10 +104,4 @@ public final class ConnectionManager: @unchecked Sendable { lock.unlock() return session } - - public func session(for connectionId: UUID) -> ConnectionSession? { - lock.lock() - defer { lock.unlock() } - return sessions[connectionId] - } } diff --git a/Packages/TableProCore/Sources/TableProDatabase/PluginDriverAdapter.swift b/Packages/TableProCore/Sources/TableProDatabase/PluginDriverAdapter.swift deleted file mode 100644 index 3e6903ba8..000000000 --- a/Packages/TableProCore/Sources/TableProDatabase/PluginDriverAdapter.swift +++ /dev/null @@ -1,102 +0,0 @@ -import Foundation -import TableProModels -import TableProPluginKit - -public final class PluginDriverAdapter: DatabaseDriver, @unchecked Sendable { - private let pluginDriver: any PluginDatabaseDriver - - public init(pluginDriver: any PluginDatabaseDriver) { - self.pluginDriver = pluginDriver - } - - public func connect() async throws { - try await pluginDriver.connect() - } - - // PluginDatabaseDriver.disconnect() is sync and non-throwing, while - // DatabaseDriver.disconnect() is async throws. A non-throwing call - // satisfies the throwing requirement, so no try is needed here. - public func disconnect() async throws { - pluginDriver.disconnect() - } - - public func ping() async throws -> Bool { - do { - try await pluginDriver.ping() - return true - } catch { - return false - } - } - - public func execute(query: String) async throws -> QueryResult { - let pluginResult = try await pluginDriver.execute(query: query) - return QueryResult(from: pluginResult) - } - - public func cancelCurrentQuery() async throws { - try pluginDriver.cancelQuery() - } - - public func fetchTables(schema: String?) async throws -> [TableInfo] { - let pluginTables = try await pluginDriver.fetchTables(schema: schema) - return pluginTables.map { TableInfo(from: $0) } - } - - public func fetchColumns(table: String, schema: String?) async throws -> [ColumnInfo] { - let pluginColumns = try await pluginDriver.fetchColumns(table: table, schema: schema) - return pluginColumns.enumerated().map { index, col in - ColumnInfo(from: col, ordinalPosition: index) - } - } - - public func fetchIndexes(table: String, schema: String?) async throws -> [IndexInfo] { - let pluginIndexes = try await pluginDriver.fetchIndexes(table: table, schema: schema) - return pluginIndexes.map { IndexInfo(from: $0) } - } - - public func fetchForeignKeys(table: String, schema: String?) async throws -> [ForeignKeyInfo] { - let pluginFKs = try await pluginDriver.fetchForeignKeys(table: table, schema: schema) - return pluginFKs.map { ForeignKeyInfo(from: $0) } - } - - public func fetchDatabases() async throws -> [String] { - try await pluginDriver.fetchDatabases() - } - - public func switchDatabase(to name: String) async throws { - try await pluginDriver.switchDatabase(to: name) - } - - public var supportsSchemas: Bool { - pluginDriver.supportsSchemas - } - - public func switchSchema(to name: String) async throws { - try await pluginDriver.switchSchema(to: name) - } - - public var currentSchema: String? { - pluginDriver.currentSchema - } - - public var supportsTransactions: Bool { - pluginDriver.supportsTransactions - } - - public func beginTransaction() async throws { - try await pluginDriver.beginTransaction() - } - - public func commitTransaction() async throws { - try await pluginDriver.commitTransaction() - } - - public func rollbackTransaction() async throws { - try await pluginDriver.rollbackTransaction() - } - - public var serverVersion: String? { - pluginDriver.serverVersion - } -} diff --git a/Packages/TableProCore/Sources/TableProDatabase/PluginMetadataProvider.swift b/Packages/TableProCore/Sources/TableProDatabase/PluginMetadataProvider.swift deleted file mode 100644 index 1817e5cc4..000000000 --- a/Packages/TableProCore/Sources/TableProDatabase/PluginMetadataProvider.swift +++ /dev/null @@ -1,64 +0,0 @@ -import Foundation -import TableProModels -import TableProPluginKit - -public final class PluginMetadataProvider: Sendable { - private let pluginLoader: PluginLoader - - public init(pluginLoader: PluginLoader) { - self.pluginLoader = pluginLoader - } - - public func displayName(for type: DatabaseType) -> String { - plugin(for: type)?.databaseDisplayName ?? type.rawValue.capitalized - } - - public func defaultPort(for type: DatabaseType) -> Int { - plugin(for: type)?.defaultPort ?? 3306 - } - - public func iconName(for type: DatabaseType) -> String { - plugin(for: type)?.iconName ?? "server.rack" - } - - public func supportsSSH(for type: DatabaseType) -> Bool { - plugin(for: type)?.supportsSSH ?? false - } - - public func supportsSSL(for type: DatabaseType) -> Bool { - plugin(for: type)?.supportsSSL ?? false - } - - public func sqlDialect(for type: DatabaseType) -> SQLDialectDescriptor? { - plugin(for: type)?.sqlDialect - } - - public func brandColorHex(for type: DatabaseType) -> String { - plugin(for: type)?.brandColorHex ?? "#808080" - } - - public func editorLanguage(for type: DatabaseType) -> EditorLanguage { - plugin(for: type)?.editorLanguage ?? .sql - } - - public func connectionMode(for type: DatabaseType) -> ConnectionMode { - plugin(for: type)?.connectionMode ?? .network - } - - public func supportsDatabaseSwitching(for type: DatabaseType) -> Bool { - plugin(for: type)?.supportsDatabaseSwitching ?? true - } - - public func supportsSchemaSwitching(for type: DatabaseType) -> Bool { - plugin(for: type)?.supportsSchemaSwitching ?? false - } - - public func groupingStrategy(for type: DatabaseType) -> GroupingStrategy { - plugin(for: type)?.databaseGroupingStrategy ?? .byDatabase - } - - private func plugin(for type: DatabaseType) -> (any DriverPlugin.Type)? { - guard let plugin = pluginLoader.driverPlugin(for: type.pluginTypeId) else { return nil } - return Swift.type(of: plugin) - } -} diff --git a/Packages/TableProCore/Sources/TableProDatabase/Protocols/DriverFactory.swift b/Packages/TableProCore/Sources/TableProDatabase/Protocols/DriverFactory.swift new file mode 100644 index 000000000..5a22a719a --- /dev/null +++ b/Packages/TableProCore/Sources/TableProDatabase/Protocols/DriverFactory.swift @@ -0,0 +1,9 @@ +import Foundation +import TableProModels + +/// Creates database drivers for a given connection. +/// macOS: plugin-based implementation. iOS: direct driver creation. +public protocol DriverFactory: Sendable { + func createDriver(for connection: DatabaseConnection, password: String?) throws -> any DatabaseDriver + func supportedTypes() -> [DatabaseType] +} diff --git a/Packages/TableProCore/Sources/TableProDatabase/Protocols/PluginLoader.swift b/Packages/TableProCore/Sources/TableProDatabase/Protocols/PluginLoader.swift deleted file mode 100644 index 033036bd1..000000000 --- a/Packages/TableProCore/Sources/TableProDatabase/Protocols/PluginLoader.swift +++ /dev/null @@ -1,7 +0,0 @@ -import Foundation -import TableProPluginKit - -public protocol PluginLoader: Sendable { - func availablePlugins() -> [any DriverPlugin] - func driverPlugin(for typeId: String) -> (any DriverPlugin)? -} diff --git a/Packages/TableProCore/Sources/TableProQuery/TableQueryBuilder.swift b/Packages/TableProCore/Sources/TableProQuery/TableQueryBuilder.swift index da33cb6cb..caecc85e5 100644 --- a/Packages/TableProCore/Sources/TableProQuery/TableQueryBuilder.swift +++ b/Packages/TableProCore/Sources/TableProQuery/TableQueryBuilder.swift @@ -2,16 +2,37 @@ import Foundation import TableProModels import TableProPluginKit +/// Allows NoSQL drivers to provide custom query building. +public protocol CustomQueryBuilder: Sendable { + func buildBrowseQuery( + table: String, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int + ) -> String? + + func buildFilteredQuery( + table: String, + filters: [(column: String, op: String, value: String)], + logicMode: String, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int + ) -> String? +} + public struct TableQueryBuilder: Sendable { private let dialect: SQLDialectDescriptor? - private let pluginDriver: (any PluginDatabaseDriver)? + private let customQueryBuilder: (any CustomQueryBuilder)? public init( dialect: SQLDialectDescriptor? = nil, - pluginDriver: (any PluginDatabaseDriver)? = nil + customQueryBuilder: (any CustomQueryBuilder)? = nil ) { self.dialect = dialect - self.pluginDriver = pluginDriver + self.customQueryBuilder = customQueryBuilder } public func buildBrowseQuery( @@ -20,11 +41,11 @@ public struct TableQueryBuilder: Sendable { limit: Int, offset: Int ) -> String { - if let driver = pluginDriver { + if let builder = customQueryBuilder { let sortColumns = sortState.columns.enumerated().map { (index, col) in (columnIndex: index, ascending: col.ascending) } - if let query = driver.buildBrowseQuery( + if let query = builder.buildBrowseQuery( table: tableName, sortColumns: sortColumns, columns: [], @@ -54,14 +75,14 @@ public struct TableQueryBuilder: Sendable { limit: Int, offset: Int ) -> String { - if let driver = pluginDriver { + if let builder = customQueryBuilder { let filterTuples = filters.filter { $0.isEnabled && $0.isValid }.map { f in (column: f.columnName, op: f.filterOperator.sqlSymbol, value: f.value) } let sortColumns = sortState.columns.enumerated().map { (index, col) in (columnIndex: index, ascending: col.ascending) } - if let query = driver.buildFilteredQuery( + if let query = builder.buildFilteredQuery( table: tableName, filters: filterTuples, logicMode: logicMode.rawValue, diff --git a/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTests.swift b/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTests.swift index fee5b216b..c8909d51e 100644 --- a/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTests.swift +++ b/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTests.swift @@ -2,70 +2,53 @@ import Testing import Foundation @testable import TableProDatabase @testable import TableProModels -@testable import TableProPluginKit // MARK: - Mock Types -private final class MockPluginDriver: PluginDatabaseDriver, @unchecked Sendable { - var connected = false - var disconnected = false +private final class MockDatabaseDriver: DatabaseDriver, @unchecked Sendable { + var isConnected = false + var shouldFailConnect = false func connect() async throws { - connected = true + if shouldFailConnect { throw NSError(domain: "test", code: 1) } + isConnected = true } - func disconnect() { - disconnected = true - } + func disconnect() async throws { isConnected = false } + func ping() async throws -> Bool { isConnected } - func execute(query: String) async throws -> PluginQueryResult { - .empty + func execute(query: String) async throws -> QueryResult { + QueryResult(columns: [], rows: [], rowsAffected: 0, executionTime: 0, isTruncated: false, statusMessage: nil) } - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } + func cancelCurrentQuery() async throws {} + func fetchTables(schema: String?) async throws -> [TableInfo] { [] } + func fetchColumns(table: String, schema: String?) async throws -> [ColumnInfo] { [] } + func fetchIndexes(table: String, schema: String?) async throws -> [IndexInfo] { [] } + func fetchForeignKeys(table: String, schema: String?) async throws -> [ForeignKeyInfo] { [] } func fetchDatabases() async throws -> [String] { [] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } + func switchDatabase(to name: String) async throws {} + var supportsSchemas: Bool { false } + func switchSchema(to name: String) async throws {} + var currentSchema: String? { nil } + var supportsTransactions: Bool { false } + func beginTransaction() async throws {} + func commitTransaction() async throws {} + func rollbackTransaction() async throws {} + var serverVersion: String? { nil } } -private final class MockDriverPlugin: DriverPlugin { - static var pluginName: String { "MockDriver" } - static var pluginVersion: String { "1.0" } - static var pluginDescription: String { "Mock" } - static var capabilities: [PluginCapability] { [.databaseDriver] } - - static var databaseTypeId: String { "mock" } - static var databaseDisplayName: String { "Mock DB" } - static var iconName: String { "server.rack" } - static var defaultPort: Int { 5432 } - - required init() {} - - private static let sharedDriver = MockPluginDriver() - - func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { - MockDriverPlugin.sharedDriver - } -} +private final class MockDriverFactory: DriverFactory, @unchecked Sendable { + var drivers: [String: any DatabaseDriver] = [:] -private final class MockPluginLoader: PluginLoader, Sendable { - func availablePlugins() -> [any DriverPlugin] { - [MockDriverPlugin()] + func createDriver(for connection: DatabaseConnection, password: String?) throws -> any DatabaseDriver { + guard let driver = drivers[connection.type.rawValue] else { + throw ConnectionError.driverNotFound(connection.type.rawValue) + } + return driver } - func driverPlugin(for typeId: String) -> (any DriverPlugin)? { - if typeId == "mock" { return MockDriverPlugin() } - return nil - } + func supportedTypes() -> [DatabaseType] { [] } } private final class MockSecureStore: SecureStore, Sendable { @@ -88,9 +71,10 @@ private final class MockSecureStore: SecureStore, Sendable { struct ConnectionManagerTests { @Test("Connect creates a session") func connectCreatesSession() async throws { - let loader = MockPluginLoader() + let factory = MockDriverFactory() + factory.drivers["mock"] = MockDatabaseDriver() let store = MockSecureStore() - let manager = ConnectionManager(pluginLoader: loader, secureStore: store) + let manager = ConnectionManager(driverFactory: factory, secureStore: store) let connection = DatabaseConnection( name: "Test", @@ -109,9 +93,10 @@ struct ConnectionManagerTests { @Test("Disconnect removes session") func disconnectRemovesSession() async throws { - let loader = MockPluginLoader() + let factory = MockDriverFactory() + factory.drivers["mock"] = MockDatabaseDriver() let store = MockSecureStore() - let manager = ConnectionManager(pluginLoader: loader, secureStore: store) + let manager = ConnectionManager(driverFactory: factory, secureStore: store) let connection = DatabaseConnection( name: "Test", @@ -125,11 +110,11 @@ struct ConnectionManagerTests { #expect(session == nil) } - @Test("Connect with unknown plugin throws error") - func connectUnknownPlugin() async throws { - let loader = MockPluginLoader() + @Test("Connect with unknown type throws driverNotFound") + func connectUnknownType() async throws { + let factory = MockDriverFactory() let store = MockSecureStore() - let manager = ConnectionManager(pluginLoader: loader, secureStore: store) + let manager = ConnectionManager(driverFactory: factory, secureStore: store) let connection = DatabaseConnection( name: "Test", @@ -143,9 +128,10 @@ struct ConnectionManagerTests { @Test("Connect with SSH but no provider throws error") func connectSSHNoProvider() async throws { - let loader = MockPluginLoader() + let factory = MockDriverFactory() + factory.drivers["mock"] = MockDatabaseDriver() let store = MockSecureStore() - let manager = ConnectionManager(pluginLoader: loader, secureStore: store, sshProvider: nil) + let manager = ConnectionManager(driverFactory: factory, secureStore: store, sshProvider: nil) var connection = DatabaseConnection( name: "Test", @@ -158,4 +144,47 @@ struct ConnectionManagerTests { _ = try await manager.connect(connection) } } + + @Test("SSH tunnel cleanup on connect failure") + func sshTunnelCleanupOnFailure() async throws { + let factory = MockDriverFactory() + let failingDriver = MockDatabaseDriver() + failingDriver.shouldFailConnect = true + factory.drivers["mock"] = failingDriver + + let store = MockSecureStore() + let sshProvider = MockSSHProvider() + let manager = ConnectionManager(driverFactory: factory, secureStore: store, sshProvider: sshProvider) + + var connection = DatabaseConnection( + name: "Test", + type: DatabaseType(rawValue: "mock") + ) + connection.sshEnabled = true + connection.sshConfiguration = SSHConfiguration(host: "jump.example.com") + + await #expect(throws: Error.self) { + _ = try await manager.connect(connection) + } + + #expect(sshProvider.closedTunnels.contains(connection.id)) + } +} + +// MARK: - Mock SSH Provider + +private final class MockSSHProvider: SSHProvider, @unchecked Sendable { + var closedTunnels: Set = [] + + func createTunnel( + config: SSHConfiguration, + remoteHost: String, + remotePort: Int + ) async throws -> SSHTunnel { + SSHTunnel(localHost: "127.0.0.1", localPort: 33306) + } + + func closeTunnel(for connectionId: UUID) async throws { + closedTunnels.insert(connectionId) + } } diff --git a/Packages/TableProCore/Tests/TableProDatabaseTests/PluginDriverAdapterTests.swift b/Packages/TableProCore/Tests/TableProDatabaseTests/PluginDriverAdapterTests.swift deleted file mode 100644 index adc8f7295..000000000 --- a/Packages/TableProCore/Tests/TableProDatabaseTests/PluginDriverAdapterTests.swift +++ /dev/null @@ -1,120 +0,0 @@ -import Testing -import Foundation -@testable import TableProDatabase -@testable import TableProModels -@testable import TableProPluginKit - -private final class StubPluginDriver: PluginDatabaseDriver, @unchecked Sendable { - var connectCalled = false - var disconnectCalled = false - - func connect() async throws { - connectCalled = true - } - - func disconnect() { - disconnectCalled = true - } - - func execute(query: String) async throws -> PluginQueryResult { - PluginQueryResult( - columns: ["id", "name"], - columnTypeNames: ["INT", "VARCHAR"], - rows: [["1", "Alice"]], - rowsAffected: 0, - executionTime: 0.01 - ) - } - - func fetchTables(schema: String?) async throws -> [PluginTableInfo] { - [PluginTableInfo(name: "users", type: "TABLE", rowCount: 42)] - } - - func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { - [PluginColumnInfo(name: "id", dataType: "INT", isPrimaryKey: true)] - } - - func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { - [PluginIndexInfo(name: "pk_id", columns: ["id"], isPrimary: true)] - } - - func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } - func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } - func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } - func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { - PluginTableMetadata(tableName: table) - } - func fetchDatabases() async throws -> [String] { ["db1", "db2"] } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { - PluginDatabaseMetadata(name: database) - } -} - -@Suite("PluginDriverAdapter Tests") -struct PluginDriverAdapterTests { - @Test("Execute maps PluginQueryResult to QueryResult") - func executeMapsResult() async throws { - let stub = StubPluginDriver() - let adapter = PluginDriverAdapter(pluginDriver: stub) - - let result = try await adapter.execute(query: "SELECT 1") - #expect(result.columns.count == 2) - #expect(result.columns[0].name == "id") - #expect(result.columns[0].typeName == "INT") - #expect(result.rows.count == 1) - } - - @Test("FetchTables maps types correctly") - func fetchTablesMaps() async throws { - let stub = StubPluginDriver() - let adapter = PluginDriverAdapter(pluginDriver: stub) - - let tables = try await adapter.fetchTables(schema: nil) - #expect(tables.count == 1) - #expect(tables[0].name == "users") - #expect(tables[0].type == .table) - #expect(tables[0].rowCount == 42) - } - - @Test("FetchColumns maps with ordinal position") - func fetchColumnsMaps() async throws { - let stub = StubPluginDriver() - let adapter = PluginDriverAdapter(pluginDriver: stub) - - let columns = try await adapter.fetchColumns(table: "users", schema: nil) - #expect(columns.count == 1) - #expect(columns[0].name == "id") - #expect(columns[0].isPrimaryKey) - #expect(columns[0].ordinalPosition == 0) - } - - @Test("Connect and disconnect delegate to plugin driver") - func connectDisconnect() async throws { - let stub = StubPluginDriver() - let adapter = PluginDriverAdapter(pluginDriver: stub) - - try await adapter.connect() - #expect(stub.connectCalled) - - try await adapter.disconnect() - #expect(stub.disconnectCalled) - } - - @Test("Ping returns true on success") - func pingSuccess() async throws { - let stub = StubPluginDriver() - let adapter = PluginDriverAdapter(pluginDriver: stub) - - let alive = try await adapter.ping() - #expect(alive) - } - - @Test("FetchDatabases returns list") - func fetchDatabases() async throws { - let stub = StubPluginDriver() - let adapter = PluginDriverAdapter(pluginDriver: stub) - - let dbs = try await adapter.fetchDatabases() - #expect(dbs == ["db1", "db2"]) - } -} From b420a1b11a296ce2e2868728aacb7967b95318c8 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 31 Mar 2026 23:20:57 +0700 Subject: [PATCH 04/61] feat: add SQLiteDriver and IOSDriverFactory, remove plugin system from iOS app --- TableProMobile/TableProMobile/AppState.swift | 11 +- .../TableProMobile/Drivers/SQLiteDriver.swift | 317 ++++++++++++++++++ .../Platform/IOSDriverFactory.swift | 23 ++ .../Platform/StaticPluginLoader.swift | 28 -- 4 files changed, 342 insertions(+), 37 deletions(-) create mode 100644 TableProMobile/TableProMobile/Drivers/SQLiteDriver.swift create mode 100644 TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift delete mode 100644 TableProMobile/TableProMobile/Platform/StaticPluginLoader.swift diff --git a/TableProMobile/TableProMobile/AppState.swift b/TableProMobile/TableProMobile/AppState.swift index 09e33eaaf..5b4286a4d 100644 --- a/TableProMobile/TableProMobile/AppState.swift +++ b/TableProMobile/TableProMobile/AppState.swift @@ -14,19 +14,12 @@ final class AppState { let connectionManager: ConnectionManager init() { - let pluginLoader = StaticPluginLoader() + let driverFactory = IOSDriverFactory() let secureStore = KeychainSecureStore() self.connectionManager = ConnectionManager( - pluginLoader: pluginLoader, + driverFactory: driverFactory, secureStore: secureStore ) - - loadSampleConnections() - } - - private func loadSampleConnections() { - // TODO: Load from persistent storage / iCloud sync - // For now, empty — user adds connections manually } func addConnection(_ connection: DatabaseConnection) { diff --git a/TableProMobile/TableProMobile/Drivers/SQLiteDriver.swift b/TableProMobile/TableProMobile/Drivers/SQLiteDriver.swift new file mode 100644 index 000000000..b2f9bbf62 --- /dev/null +++ b/TableProMobile/TableProMobile/Drivers/SQLiteDriver.swift @@ -0,0 +1,317 @@ +// +// SQLiteDriver.swift +// TableProMobile +// +// SQLite driver conforming to DatabaseDriver directly (no plugin layer). +// + +import Foundation +import SQLite3 +import TableProDatabase +import TableProModels + +final class SQLiteDriver: DatabaseDriver, @unchecked Sendable { + private let dbPath: String + private let actor = SQLiteActor() + private let interruptLock = NSLock() + nonisolated(unsafe) private var interruptHandle: OpaquePointer? + + var supportsSchemas: Bool { false } + var currentSchema: String? { nil } + var supportsTransactions: Bool { true } + var serverVersion: String? { String(cString: sqlite3_libversion()) } + + init(path: String) { + self.dbPath = path + } + + // MARK: - Connection + + func connect() async throws { + let expanded = (dbPath as NSString).expandingTildeInPath + + if !FileManager.default.fileExists(atPath: expanded) { + let dir = (expanded as NSString).deletingLastPathComponent + try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + } + + try await actor.open(path: expanded) + let handle = await actor.rawHandle + interruptLock.lock() + interruptHandle = handle + interruptLock.unlock() + } + + func disconnect() async throws { + interruptLock.lock() + interruptHandle = nil + interruptLock.unlock() + await actor.close() + } + + func ping() async throws -> Bool { + _ = try await actor.execute("SELECT 1") + return true + } + + // MARK: - Query Execution + + func execute(query: String) async throws -> QueryResult { + let raw = try await actor.execute(query) + return QueryResult( + columns: raw.columns.enumerated().map { i, name in + ColumnInfo( + name: name, + typeName: i < raw.columnTypes.count ? raw.columnTypes[i] : "", + isPrimaryKey: false, + isNullable: true, + defaultValue: nil, + comment: nil, + characterMaxLength: nil, + ordinalPosition: i + ) + }, + rows: raw.rows, + rowsAffected: raw.rowsAffected, + executionTime: raw.executionTime, + isTruncated: raw.isTruncated, + statusMessage: nil + ) + } + + func cancelCurrentQuery() async throws { + interruptLock.lock() + let db = interruptHandle + interruptLock.unlock() + if let db { sqlite3_interrupt(db) } + } + + // MARK: - Schema + + func fetchTables(schema: String?) async throws -> [TableInfo] { + let raw = try await actor.execute(""" + SELECT name, type FROM sqlite_master + WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' + ORDER BY name + """) + + return raw.rows.compactMap { row in + guard let name = row[safe: 0] ?? nil else { return nil } + let kind: TableInfo.TableKind = (row[safe: 1] ?? nil)?.lowercased() == "view" ? .view : .table + return TableInfo(name: name, type: kind, rowCount: nil, dataSize: nil, comment: nil) + } + } + + func fetchColumns(table: String, schema: String?) async throws -> [ColumnInfo] { + let safe = table.replacingOccurrences(of: "'", with: "''") + let raw = try await actor.execute("PRAGMA table_info('\(safe)')") + + return raw.rows.enumerated().compactMap { index, row in + guard row.count >= 6, let name = row[1], let dataType = row[2] else { return nil } + return ColumnInfo( + name: name, + typeName: dataType, + isPrimaryKey: row[5] == "1", + isNullable: row[3] == "0", + defaultValue: row[4], + comment: nil, + characterMaxLength: nil, + ordinalPosition: index + ) + } + } + + func fetchIndexes(table: String, schema: String?) async throws -> [IndexInfo] { + let safe = table.replacingOccurrences(of: "'", with: "''") + let raw = try await actor.execute(""" + SELECT il.name, il."unique", il.origin, ii.name AS col_name + FROM pragma_index_list('\(safe)') il + LEFT JOIN pragma_index_info(il.name) ii ON 1=1 + ORDER BY il.seq, ii.seqno + """) + + var indexMap: [String: (isUnique: Bool, isPrimary: Bool, columns: [String])] = [:] + var order: [String] = [] + + for row in raw.rows { + guard row.count >= 4, let indexName = row[0] else { continue } + if indexMap[indexName] == nil { + indexMap[indexName] = ( + isUnique: row[1] == "1", + isPrimary: (row[2] ?? "c") == "pk", + columns: [] + ) + order.append(indexName) + } + if let col = row[3] { + indexMap[indexName]?.columns.append(col) + } + } + + return order.compactMap { name in + guard let entry = indexMap[name] else { return nil } + return IndexInfo( + name: name, + columns: entry.columns, + isUnique: entry.isUnique, + isPrimary: entry.isPrimary, + type: "BTREE" + ) + } + } + + func fetchForeignKeys(table: String, schema: String?) async throws -> [ForeignKeyInfo] { + let safe = table.replacingOccurrences(of: "'", with: "''") + let raw = try await actor.execute("PRAGMA foreign_key_list('\(safe)')") + + return raw.rows.compactMap { row in + guard row.count >= 5, + let refTable = row[2], + let fromCol = row[3], + let toCol = row[4] else { return nil } + + return ForeignKeyInfo( + name: "fk_\(table)_\(row[0] ?? "0")", + column: fromCol, + referencedTable: refTable, + referencedColumn: toCol, + onDelete: row.count >= 7 ? (row[6] ?? "NO ACTION") : "NO ACTION", + onUpdate: row.count >= 6 ? (row[5] ?? "NO ACTION") : "NO ACTION" + ) + } + } + + func fetchDatabases() async throws -> [String] { [] } + + func switchDatabase(to name: String) async throws { + throw SQLiteError.unsupported("SQLite does not support database switching") + } + + func switchSchema(to name: String) async throws { + throw SQLiteError.unsupported("SQLite does not support schemas") + } + + func beginTransaction() async throws { + _ = try await actor.execute("BEGIN TRANSACTION") + } + + func commitTransaction() async throws { + _ = try await actor.execute("COMMIT") + } + + func rollbackTransaction() async throws { + _ = try await actor.execute("ROLLBACK") + } +} + +// MARK: - SQLite Actor (thread-safe C API access) + +private actor SQLiteActor { + private var db: OpaquePointer? + + var rawHandle: OpaquePointer? { db } + + func open(path: String) throws { + if sqlite3_open(path, &db) != SQLITE_OK { + let msg = db.map { String(cString: sqlite3_errmsg($0)) } ?? "Unknown error" + throw SQLiteError.connectionFailed(msg) + } + sqlite3_busy_timeout(db, 5000) + } + + func close() { + if let db { + sqlite3_close(db) + self.db = nil + } + } + + func execute(_ query: String) throws -> RawResult { + guard let db else { throw SQLiteError.notConnected } + + let start = Date() + var stmt: OpaquePointer? + + guard sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE_OK else { + throw SQLiteError.queryFailed(String(cString: sqlite3_errmsg(db))) + } + defer { sqlite3_finalize(stmt) } + + let colCount = sqlite3_column_count(stmt) + var columns: [String] = [] + var columnTypes: [String] = [] + + for i in 0..= maxRows { + return RawResult(columns: columns, columnTypes: columnTypes, rows: rows, + rowsAffected: 0, executionTime: Date().timeIntervalSince(start), isTruncated: true) + } + + var row: [String?] = [] + for i in 0.. 0, let ptr = sqlite3_column_blob(stmt, i) { + row.append(Data(bytes: ptr, count: bytes).base64EncodedString()) + } else { + row.append("") + } + } else if let text = sqlite3_column_text(stmt, i) { + row.append(String(cString: text)) + } else { + row.append(nil) + } + } + rows.append(row) + } + + let affected = columns.isEmpty ? Int(sqlite3_changes(db)) : 0 + return RawResult(columns: columns, columnTypes: columnTypes, rows: rows, + rowsAffected: affected, executionTime: Date().timeIntervalSince(start), isTruncated: false) + } +} + +private struct RawResult: Sendable { + let columns: [String] + let columnTypes: [String] + let rows: [[String?]] + let rowsAffected: Int + let executionTime: TimeInterval + let isTruncated: Bool +} + +// MARK: - Errors + +enum SQLiteError: Error, LocalizedError { + case connectionFailed(String) + case notConnected + case queryFailed(String) + case unsupported(String) + + var errorDescription: String? { + switch self { + case .connectionFailed(let msg): return "SQLite connection failed: \(msg)" + case .notConnected: return "Not connected to SQLite database" + case .queryFailed(let msg): return "SQLite query failed: \(msg)" + case .unsupported(let msg): return msg + } + } +} + +// MARK: - Array Safe Subscript + +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift b/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift new file mode 100644 index 000000000..33511636b --- /dev/null +++ b/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift @@ -0,0 +1,23 @@ +// +// IOSDriverFactory.swift +// TableProMobile +// + +import Foundation +import TableProDatabase +import TableProModels + +final class IOSDriverFactory: DriverFactory { + func createDriver(for connection: DatabaseConnection, password: String?) throws -> any DatabaseDriver { + switch connection.type { + case .sqlite: + return SQLiteDriver(path: connection.database) + default: + throw ConnectionError.driverNotFound(connection.type.rawValue) + } + } + + func supportedTypes() -> [DatabaseType] { + [.sqlite] + } +} diff --git a/TableProMobile/TableProMobile/Platform/StaticPluginLoader.swift b/TableProMobile/TableProMobile/Platform/StaticPluginLoader.swift deleted file mode 100644 index 625dcf6de..000000000 --- a/TableProMobile/TableProMobile/Platform/StaticPluginLoader.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// StaticPluginLoader.swift -// TableProMobile -// -// iOS plugin loader — returns compiled-in drivers. -// macOS uses BundlePluginLoader (runtime .tableplugin loading). -// - -import Foundation -import TableProDatabase -@preconcurrency import TableProPluginKit - -final class StaticPluginLoader: PluginLoader, Sendable { - nonisolated(unsafe) private let plugins: [any DriverPlugin] - - init() { - // TODO: Register compiled-in plugins when C libs are cross-compiled for iOS - plugins = [] - } - - func availablePlugins() -> [any DriverPlugin] { - plugins - } - - func driverPlugin(for typeId: String) -> (any DriverPlugin)? { - plugins.first { type(of: $0).databaseTypeId == typeId } - } -} From 7f13dac259f70361e6b3f66917d019d067ce4744 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 31 Mar 2026 23:27:08 +0700 Subject: [PATCH 05/61] feat: add SQLite file picker, document picker, and password storage for iOS --- .../TableProDatabase/ConnectionManager.swift | 8 + .../Views/ConnectionFormView.swift | 218 ++++++++++++++++-- 2 files changed, 204 insertions(+), 22 deletions(-) diff --git a/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift b/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift index 9bba76807..c97f712a1 100644 --- a/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift +++ b/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift @@ -61,6 +61,14 @@ public final class ConnectionManager: @unchecked Sendable { } } + public func storePassword(_ password: String, for connectionId: UUID) throws { + try secureStore.store(password, forKey: connectionId.uuidString) + } + + public func deletePassword(for connectionId: UUID) throws { + try secureStore.delete(forKey: connectionId.uuidString) + } + public func disconnect(_ connectionId: UUID) async { let session = removeSession(for: connectionId) guard let session else { return } diff --git a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift index df7226e9c..9d335aed0 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift @@ -5,9 +5,11 @@ import SwiftUI import TableProModels +import UniformTypeIdentifiers struct ConnectionFormView: View { @Environment(\.dismiss) private var dismiss + @Environment(AppState.self) private var appState @State private var name = "" @State private var type: DatabaseType = .mysql @@ -18,6 +20,12 @@ struct ConnectionFormView: View { @State private var database = "" @State private var sslEnabled = false + // SQLite file picker + @State private var showFilePicker = false + @State private var selectedFileURL: URL? + @State private var showNewDatabaseAlert = false + @State private var newDatabaseName = "" + var onSave: (DatabaseConnection) -> Void private let databaseTypes: [(DatabaseType, String)] = [ @@ -41,28 +49,15 @@ struct ConnectionFormView: View { } .onChange(of: type) { _, newType in updateDefaultPort(for: newType) + selectedFileURL = nil + database = "" } } - if type != .sqlite { - Section("Server") { - TextField("Host", text: $host) - .textInputAutocapitalization(.never) - .keyboardType(.URL) - - TextField("Port", text: $port) - .keyboardType(.numberPad) - - TextField("Username", text: $username) - .textInputAutocapitalization(.never) - - SecureField("Password", text: $password) - } - } - - Section("Database") { - TextField(type == .sqlite ? "File Path" : "Database Name", text: $database) - .textInputAutocapitalization(.never) + if type == .sqlite { + sqliteSection + } else { + serverSection } if type != .sqlite && type != .redis { @@ -79,10 +74,102 @@ struct ConnectionFormView: View { } ToolbarItem(placement: .confirmationAction) { Button("Save") { save() } - .disabled(host.isEmpty && type != .sqlite) + .disabled(!canSave) + } + } + .fileImporter( + isPresented: $showFilePicker, + allowedContentTypes: sqliteContentTypes, + allowsMultipleSelection: false + ) { result in + handleFilePickerResult(result) + } + .alert("New Database", isPresented: $showNewDatabaseAlert) { + TextField("Database name", text: $newDatabaseName) + Button("Create") { createNewDatabase() } + Button("Cancel", role: .cancel) { newDatabaseName = "" } + } message: { + Text("Enter a name for the new SQLite database.") + } + } + } + + // MARK: - SQLite Section + + private var sqliteSection: some View { + Section("Database File") { + if let url = selectedFileURL { + HStack { + Image(systemName: "doc.fill") + .foregroundStyle(.blue) + VStack(alignment: .leading) { + Text(url.lastPathComponent) + .font(.body) + Text(url.deletingLastPathComponent().lastPathComponent) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button { + selectedFileURL = nil + database = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } } } + + Button { + showFilePicker = true + } label: { + Label("Open Database File", systemImage: "folder") + } + + Button { + showNewDatabaseAlert = true + } label: { + Label("Create New Database", systemImage: "plus.circle") + } + } + } + + // MARK: - Server Section (MySQL, PostgreSQL, Redis) + + private var serverSection: some View { + Group { + Section("Server") { + TextField("Host", text: $host) + .textInputAutocapitalization(.never) + .keyboardType(.URL) + + TextField("Port", text: $port) + .keyboardType(.numberPad) + + TextField("Username", text: $username) + .textInputAutocapitalization(.never) + + SecureField("Password", text: $password) + } + + Section("Database") { + TextField("Database Name", text: $database) + .textInputAutocapitalization(.never) + } + } + } + + // MARK: - Logic + + private var canSave: Bool { + if type == .sqlite { + return !database.isEmpty } + return !host.isEmpty + } + + private var sqliteContentTypes: [UTType] { + [UTType.database, UTType(filenameExtension: "sqlite3") ?? .data, .data] } private func updateDefaultPort(for type: DatabaseType) { @@ -95,9 +182,68 @@ struct ConnectionFormView: View { } } + private func handleFilePickerResult(_ result: Result<[URL], Error>) { + guard case .success(let urls) = result, let url = urls.first else { return } + + // Save security-scoped bookmark for reopening after app restart + guard url.startAccessingSecurityScopedResource() else { return } + defer { url.stopAccessingSecurityScopedResource() } + + do { + let bookmarkData = try url.bookmarkData( + options: .minimalBookmark, + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + // Copy file to app's Documents for reliable access + let destURL = copyToDocuments(url) + selectedFileURL = destURL + database = destURL.path + if name.isEmpty { + name = destURL.deletingPathExtension().lastPathComponent + } + + // Store bookmark for original location reference + BookmarkStore.save(bookmarkData, for: destURL.lastPathComponent) + } catch { + // Fallback: just use the file path directly + selectedFileURL = url + database = url.path + if name.isEmpty { + name = url.deletingPathExtension().lastPathComponent + } + } + } + + private func copyToDocuments(_ sourceURL: URL) -> URL { + let documentsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + let destURL = documentsDir.appendingPathComponent(sourceURL.lastPathComponent) + + if !FileManager.default.fileExists(atPath: destURL.path) { + try? FileManager.default.copyItem(at: sourceURL, to: destURL) + } + return destURL + } + + private func createNewDatabase() { + guard !newDatabaseName.isEmpty else { return } + + let safeName = newDatabaseName.hasSuffix(".db") ? newDatabaseName : "\(newDatabaseName).db" + let documentsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + let fileURL = documentsDir.appendingPathComponent(safeName) + + // SQLite creates the file on first connect — just set the path + selectedFileURL = fileURL + database = fileURL.path + if name.isEmpty { + name = newDatabaseName + } + newDatabaseName = "" + } + private func save() { let connection = DatabaseConnection( - name: name.isEmpty ? host : name, + name: name.isEmpty ? (selectedFileURL?.lastPathComponent ?? host) : name, type: type, host: host, port: Int(port) ?? 3306, @@ -105,7 +251,35 @@ struct ConnectionFormView: View { database: database, sslEnabled: sslEnabled ) - // TODO: Store password in KeychainSecureStore + + if !password.isEmpty { + try? appState.connectionManager.storePassword(password, for: connection.id) + } + onSave(connection) } } + +// MARK: - Bookmark Storage + +enum BookmarkStore { + private static let key = "com.TablePro.Mobile.bookmarks" + + static func save(_ data: Data, for filename: String) { + var bookmarks = loadAll() + bookmarks[filename] = data + UserDefaults.standard.set(try? JSONEncoder().encode(bookmarks), forKey: key) + } + + static func load(for filename: String) -> Data? { + loadAll()[filename] + } + + private static func loadAll() -> [String: Data] { + guard let data = UserDefaults.standard.data(forKey: key), + let dict = try? JSONDecoder().decode([String: Data].self, from: data) else { + return [:] + } + return dict + } +} From f8215ed3a3d02daa1932bc791ee684be32b3c526 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 31 Mar 2026 23:28:36 +0700 Subject: [PATCH 06/61] fix: resolve Swift 6 concurrency errors in SQLiteDriver, add missing import --- .../TableProMobile/Drivers/SQLiteDriver.swift | 32 ++++--------------- .../Views/ConnectionFormView.swift | 1 + 2 files changed, 8 insertions(+), 25 deletions(-) diff --git a/TableProMobile/TableProMobile/Drivers/SQLiteDriver.swift b/TableProMobile/TableProMobile/Drivers/SQLiteDriver.swift index b2f9bbf62..601ba35c2 100644 --- a/TableProMobile/TableProMobile/Drivers/SQLiteDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/SQLiteDriver.swift @@ -13,8 +13,6 @@ import TableProModels final class SQLiteDriver: DatabaseDriver, @unchecked Sendable { private let dbPath: String private let actor = SQLiteActor() - private let interruptLock = NSLock() - nonisolated(unsafe) private var interruptHandle: OpaquePointer? var supportsSchemas: Bool { false } var currentSchema: String? { nil } @@ -36,16 +34,9 @@ final class SQLiteDriver: DatabaseDriver, @unchecked Sendable { } try await actor.open(path: expanded) - let handle = await actor.rawHandle - interruptLock.lock() - interruptHandle = handle - interruptLock.unlock() } func disconnect() async throws { - interruptLock.lock() - interruptHandle = nil - interruptLock.unlock() await actor.close() } @@ -80,10 +71,7 @@ final class SQLiteDriver: DatabaseDriver, @unchecked Sendable { } func cancelCurrentQuery() async throws { - interruptLock.lock() - let db = interruptHandle - interruptLock.unlock() - if let db { sqlite3_interrupt(db) } + await actor.interrupt() } // MARK: - Schema @@ -96,8 +84,8 @@ final class SQLiteDriver: DatabaseDriver, @unchecked Sendable { """) return raw.rows.compactMap { row in - guard let name = row[safe: 0] ?? nil else { return nil } - let kind: TableInfo.TableKind = (row[safe: 1] ?? nil)?.lowercased() == "view" ? .view : .table + guard row.count > 0, let name = row[0] else { return nil } + let kind: TableInfo.TableKind = (row.count > 1 ? row[1] : nil)?.lowercased() == "view" ? .view : .table return TableInfo(name: name, type: kind, rowCount: nil, dataSize: nil, comment: nil) } } @@ -209,8 +197,6 @@ final class SQLiteDriver: DatabaseDriver, @unchecked Sendable { private actor SQLiteActor { private var db: OpaquePointer? - var rawHandle: OpaquePointer? { db } - func open(path: String) throws { if sqlite3_open(path, &db) != SQLITE_OK { let msg = db.map { String(cString: sqlite3_errmsg($0)) } ?? "Unknown error" @@ -226,6 +212,10 @@ private actor SQLiteActor { } } + func interrupt() { + if let db { sqlite3_interrupt(db) } + } + func execute(_ query: String) throws -> RawResult { guard let db else { throw SQLiteError.notConnected } @@ -307,11 +297,3 @@ enum SQLiteError: Error, LocalizedError { } } } - -// MARK: - Array Safe Subscript - -private extension Array { - subscript(safe index: Int) -> Element? { - indices.contains(index) ? self[index] : nil - } -} diff --git a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift index 9d335aed0..08bec922f 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift @@ -4,6 +4,7 @@ // import SwiftUI +import TableProDatabase import TableProModels import UniformTypeIdentifiers From 5439580ec1c18c3b2c9c24e795d07db0d4439408 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 31 Mar 2026 23:31:04 +0700 Subject: [PATCH 07/61] feat: add SQL query editor for iOS app --- .../Views/QueryEditorView.swift | 150 ++++++++++++++++++ .../TableProMobile/Views/TableListView.swift | 9 ++ 2 files changed, 159 insertions(+) create mode 100644 TableProMobile/TableProMobile/Views/QueryEditorView.swift diff --git a/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/TableProMobile/TableProMobile/Views/QueryEditorView.swift new file mode 100644 index 000000000..18bb1aaa6 --- /dev/null +++ b/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -0,0 +1,150 @@ +// +// QueryEditorView.swift +// TableProMobile +// + +import SwiftUI +import TableProDatabase +import TableProModels + +struct QueryEditorView: View { + let session: ConnectionSession? + + @State private var query = "" + @State private var result: QueryResult? + @State private var errorMessage: String? + @State private var isExecuting = false + @State private var executionTime: TimeInterval? + + var body: some View { + VStack(spacing: 0) { + editorArea + Divider() + resultArea + } + .navigationTitle("Query") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + Task { await executeQuery() } + } label: { + Image(systemName: isExecuting ? "stop.fill" : "play.fill") + } + .disabled(query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isExecuting) + } + } + } + + private var editorArea: some View { + VStack(spacing: 0) { + TextEditor(text: $query) + .font(.system(.body, design: .monospaced)) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .frame(minHeight: 120, maxHeight: 200) + .padding(.horizontal, 8) + .padding(.vertical, 4) + + HStack { + if let time = executionTime { + Text(String(format: "%.2fms", time * 1000)) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if let result, !result.rows.isEmpty { + Text("\(result.rows.count) rows") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 12) + .padding(.bottom, 6) + } + } + + private var resultArea: some View { + Group { + if isExecuting { + ProgressView("Executing...") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let errorMessage { + ScrollView { + Text(errorMessage) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(.red) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + } else if let result { + if result.columns.isEmpty { + VStack { + Image(systemName: "checkmark.circle.fill") + .font(.largeTitle) + .foregroundStyle(.green) + Text("\(result.rowsAffected) row(s) affected") + .font(.body) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if result.rows.isEmpty { + ContentUnavailableView("No Results", systemImage: "tray") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + resultTable(result) + } + } else { + ContentUnavailableView { + Label("Run a Query", systemImage: "terminal") + } description: { + Text("Write SQL and tap the play button.") + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } + + private func resultTable(_ result: QueryResult) -> some View { + List { + ForEach(Array(result.rows.enumerated()), id: \.offset) { _, row in + VStack(alignment: .leading, spacing: 4) { + ForEach(Array(zip(result.columns, row)), id: \.0.name) { col, value in + HStack(spacing: 6) { + Text(col.name) + .font(.caption) + .foregroundStyle(.secondary) + .frame(width: 100, alignment: .trailing) + + Text(value ?? "NULL") + .font(.system(.body, design: .monospaced)) + .foregroundStyle(value == nil ? .secondary : .primary) + .lineLimit(2) + } + } + } + .padding(.vertical, 4) + } + } + .listStyle(.plain) + } + + private func executeQuery() async { + guard let session else { return } + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + isExecuting = true + errorMessage = nil + result = nil + + do { + let queryResult = try await session.driver.execute(query: trimmed) + self.result = queryResult + self.executionTime = queryResult.executionTime + } catch { + self.errorMessage = error.localizedDescription + } + + isExecuting = false + } +} diff --git a/TableProMobile/TableProMobile/Views/TableListView.swift b/TableProMobile/TableProMobile/Views/TableListView.swift index 8736212ed..cfb70bc01 100644 --- a/TableProMobile/TableProMobile/Views/TableListView.swift +++ b/TableProMobile/TableProMobile/Views/TableListView.swift @@ -29,6 +29,15 @@ struct TableListView: View { } .searchable(text: $searchText, prompt: "Search tables") .navigationTitle("Tables") + .toolbar { + ToolbarItem(placement: .primaryAction) { + NavigationLink { + QueryEditorView(session: session) + } label: { + Image(systemName: "terminal") + } + } + } .navigationDestination(for: TableInfo.self) { table in DataBrowserView( connection: connection, From dd7f9b456fccb671007864388d272479c4b06a7c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 31 Mar 2026 23:45:39 +0700 Subject: [PATCH 08/61] fix: move navigationDestination to NavigationStack level for connection links --- .../TableProMobile/Views/ConnectedView.swift | 80 +++++- .../Views/ConnectionFormView.swift | 91 ++++++- .../Views/ConnectionListView.swift | 109 ++++++-- .../Views/DataBrowserView.swift | 109 +++++--- .../Views/QueryEditorView.swift | 239 +++++++++++++++--- .../TableProMobile/Views/RowDetailView.swift | 126 ++++++--- .../TableProMobile/Views/TableListView.swift | 101 ++++++-- 7 files changed, 685 insertions(+), 170 deletions(-) diff --git a/TableProMobile/TableProMobile/Views/ConnectedView.swift b/TableProMobile/TableProMobile/Views/ConnectedView.swift index 92b84e5cd..d887b3844 100644 --- a/TableProMobile/TableProMobile/Views/ConnectedView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectedView.swift @@ -2,8 +2,6 @@ // ConnectedView.swift // TableProMobile // -// Wrapper that connects to the database, then shows TableListView. -// import SwiftUI import TableProDatabase @@ -17,11 +15,17 @@ struct ConnectedView: View { @State private var tables: [TableInfo] = [] @State private var isConnecting = true @State private var errorMessage: String? + @State private var selectedTab = 0 + + private var displayName: String { + connection.name.isEmpty ? connection.host : connection.name + } var body: some View { Group { if isConnecting { - ProgressView("Connecting to \(connection.name)...") + ProgressView("Connecting to \(displayName)...") + .frame(maxWidth: .infinity, maxHeight: .infinity) } else if let errorMessage { ContentUnavailableView { Label("Connection Failed", systemImage: "exclamationmark.triangle") @@ -34,16 +38,63 @@ struct ConnectedView: View { .buttonStyle(.borderedProminent) } } else { - TableListView( - connection: connection, - tables: tables, - session: session - ) + connectedTabs } } - .navigationTitle(connection.name.isEmpty ? connection.host : connection.name) - .navigationBarTitleDisplayMode(.inline) + .toolbar(session != nil && errorMessage == nil ? .hidden : .visible, for: .navigationBar) .task { await connect() } + .onDisappear { + Task { + if let session { + try? await session.driver.disconnect() + } + } + } + } + + private var connectedTabs: some View { + TabView(selection: $selectedTab) { + Tab("Tables", systemImage: "tablecells", value: 0) { + NavigationStack { + TableListView( + connection: connection, + tables: tables, + session: session, + onRefresh: { await refreshTables() } + ) + .toolbar { + ToolbarItem(placement: .status) { + connectionStatusBadge + } + } + } + } + + Tab("Query", systemImage: "terminal", value: 1) { + NavigationStack { + QueryEditorView( + session: session, + tables: tables + ) + .toolbar { + ToolbarItem(placement: .status) { + connectionStatusBadge + } + } + } + } + } + } + + private var connectionStatusBadge: some View { + HStack(spacing: 4) { + Circle() + .fill(.green) + .frame(width: 6, height: 6) + Text(displayName) + .font(.caption2) + .foregroundStyle(.secondary) + } } private func connect() async { @@ -60,4 +111,13 @@ struct ConnectedView: View { isConnecting = false } } + + private func refreshTables() async { + guard let session else { return } + do { + self.tables = try await session.driver.fetchTables(schema: nil) + } catch { + // Keep existing tables on refresh failure + } + } } diff --git a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift index 08bec922f..202dad414 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift @@ -27,6 +27,11 @@ struct ConnectionFormView: View { @State private var showNewDatabaseAlert = false @State private var newDatabaseName = "" + // Test connection + @State private var isTesting = false + @State private var testResult: TestResult? + + private let existingConnection: DatabaseConnection? var onSave: (DatabaseConnection) -> Void private let databaseTypes: [(DatabaseType, String)] = [ @@ -36,6 +41,23 @@ struct ConnectionFormView: View { (.redis, "Redis"), ] + init(editing connection: DatabaseConnection? = nil, onSave: @escaping (DatabaseConnection) -> Void) { + self.existingConnection = connection + self.onSave = onSave + if let connection { + _name = State(initialValue: connection.name) + _type = State(initialValue: connection.type) + _host = State(initialValue: connection.host) + _port = State(initialValue: String(connection.port)) + _username = State(initialValue: connection.username) + _database = State(initialValue: connection.database) + _sslEnabled = State(initialValue: connection.sslEnabled) + if connection.type == .sqlite { + _selectedFileURL = State(initialValue: URL(fileURLWithPath: connection.database)) + } + } + } + var body: some View { NavigationStack { Form { @@ -66,8 +88,36 @@ struct ConnectionFormView: View { Toggle("SSL", isOn: $sslEnabled) } } + + Section { + Button { + Task { await testConnection() } + } label: { + HStack { + if isTesting { + ProgressView() + .controlSize(.small) + Text("Testing...") + } else { + Label("Test Connection", systemImage: "antenna.radiowaves.left.and.right") + } + } + } + .disabled(isTesting || !canSave) + + if let testResult { + HStack(spacing: 8) { + Image(systemName: testResult.success ? "checkmark.circle.fill" : "xmark.circle.fill") + .foregroundStyle(testResult.success ? .green : .red) + Text(testResult.message) + .font(.footnote) + .foregroundStyle(testResult.success ? .green : .red) + } + } + } } - .navigationTitle("New Connection") + .scrollDismissesKeyboard(.interactively) + .navigationTitle(existingConnection != nil ? "Edit Connection" : "New Connection") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { @@ -186,7 +236,6 @@ struct ConnectionFormView: View { private func handleFilePickerResult(_ result: Result<[URL], Error>) { guard case .success(let urls) = result, let url = urls.first else { return } - // Save security-scoped bookmark for reopening after app restart guard url.startAccessingSecurityScopedResource() else { return } defer { url.stopAccessingSecurityScopedResource() } @@ -196,7 +245,6 @@ struct ConnectionFormView: View { includingResourceValuesForKeys: nil, relativeTo: nil ) - // Copy file to app's Documents for reliable access let destURL = copyToDocuments(url) selectedFileURL = destURL database = destURL.path @@ -204,10 +252,8 @@ struct ConnectionFormView: View { name = destURL.deletingPathExtension().lastPathComponent } - // Store bookmark for original location reference BookmarkStore.save(bookmarkData, for: destURL.lastPathComponent) } catch { - // Fallback: just use the file path directly selectedFileURL = url database = url.path if name.isEmpty { @@ -233,7 +279,6 @@ struct ConnectionFormView: View { let documentsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! let fileURL = documentsDir.appendingPathComponent(safeName) - // SQLite creates the file on first connect — just set the path selectedFileURL = fileURL database = fileURL.path if name.isEmpty { @@ -242,8 +287,29 @@ struct ConnectionFormView: View { newDatabaseName = "" } - private func save() { - let connection = DatabaseConnection( + private func testConnection() async { + isTesting = true + testResult = nil + + let connection = buildConnection() + if !password.isEmpty { + try? appState.connectionManager.storePassword(password, for: connection.id) + } + + do { + let session = try await appState.connectionManager.connect(connection) + try? await session.driver.disconnect() + testResult = TestResult(success: true, message: "Connection successful") + } catch { + testResult = TestResult(success: false, message: error.localizedDescription) + } + + isTesting = false + } + + private func buildConnection() -> DatabaseConnection { + DatabaseConnection( + id: existingConnection?.id ?? UUID(), name: name.isEmpty ? (selectedFileURL?.lastPathComponent ?? host) : name, type: type, host: host, @@ -252,6 +318,10 @@ struct ConnectionFormView: View { database: database, sslEnabled: sslEnabled ) + } + + private func save() { + let connection = buildConnection() if !password.isEmpty { try? appState.connectionManager.storePassword(password, for: connection.id) @@ -261,6 +331,11 @@ struct ConnectionFormView: View { } } +private struct TestResult { + let success: Bool + let message: String +} + // MARK: - Bookmark Storage enum BookmarkStore { diff --git a/TableProMobile/TableProMobile/Views/ConnectionListView.swift b/TableProMobile/TableProMobile/Views/ConnectionListView.swift index fe60405f8..b967b06fe 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionListView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionListView.swift @@ -9,6 +9,12 @@ import TableProModels struct ConnectionListView: View { @Environment(AppState.self) private var appState @State private var showingAddConnection = false + @State private var editingConnection: DatabaseConnection? + + private var groupedConnections: [(String, [DatabaseConnection])] { + let grouped = Dictionary(grouping: appState.connections) { $0.type.rawValue.capitalized } + return grouped.sorted { $0.key < $1.key } + } var body: some View { NavigationStack { @@ -35,6 +41,16 @@ struct ConnectionListView: View { showingAddConnection = false } } + .sheet(item: $editingConnection) { connection in + ConnectionFormView(editing: connection) { updated in + appState.removeConnection(connection) + appState.addConnection(updated) + editingConnection = nil + } + } + .navigationDestination(for: DatabaseConnection.self) { connection in + ConnectedView(connection: connection) + } } } @@ -53,42 +69,83 @@ struct ConnectionListView: View { private var connectionList: some View { List { - ForEach(appState.connections) { connection in - NavigationLink(value: connection) { - ConnectionRow(connection: connection) - } - } - .onDelete { indexSet in - for index in indexSet { - appState.removeConnection(appState.connections[index]) + ForEach(groupedConnections, id: \.0) { sectionTitle, connections in + Section(sectionTitle) { + ForEach(connections) { connection in + NavigationLink(value: connection) { + ConnectionRow(connection: connection) + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + appState.removeConnection(connection) + } label: { + Label("Delete", systemImage: "trash") + } + } + .contextMenu { + Button { + editingConnection = connection + } label: { + Label("Edit", systemImage: "pencil") + } + Button { + var duplicate = connection + duplicate.id = UUID() + duplicate.name = "\(connection.name) Copy" + appState.addConnection(duplicate) + } label: { + Label("Duplicate", systemImage: "doc.on.doc") + } + Divider() + Button(role: .destructive) { + appState.removeConnection(connection) + } label: { + Label("Delete", systemImage: "trash") + } + } + } } } } - .navigationDestination(for: DatabaseConnection.self) { connection in - ConnectedView(connection: connection) - } + .listStyle(.insetGrouped) } } -struct ConnectionRow: View { +private struct ConnectionRow: View { let connection: DatabaseConnection var body: some View { HStack(spacing: 12) { Image(systemName: iconName(for: connection.type)) .font(.title2) - .foregroundStyle(.secondary) - .frame(width: 32) + .foregroundStyle(iconColor(for: connection.type)) + .frame(width: 36, height: 36) + .background(iconColor(for: connection.type).opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 8)) VStack(alignment: .leading, spacing: 2) { Text(connection.name.isEmpty ? connection.host : connection.name) .font(.body) .fontWeight(.medium) - Text("\(connection.type.rawValue) — \(connection.host):\(connection.port)") - .font(.caption) - .foregroundStyle(.secondary) + HStack(spacing: 4) { + if connection.type != .sqlite { + Text("\(connection.host):\(connection.port)") + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text(connection.database.components(separatedBy: "/").last ?? "database") + .font(.caption) + .foregroundStyle(.secondary) + } + } } + + Spacer() + + Circle() + .fill(.green.opacity(0.8)) + .frame(width: 8, height: 8) } .padding(.vertical, 4) } @@ -99,7 +156,23 @@ struct ConnectionRow: View { case .postgresql, .redshift: return "elephant" case .sqlite: return "doc" case .redis: return "key" - default: return "server.rack" + case .mongodb: return "leaf" + case .clickhouse: return "bolt" + case .mssql: return "server.rack" + default: return "externaldrive" + } + } + + private func iconColor(for type: DatabaseType) -> Color { + switch type { + case .mysql, .mariadb: return .orange + case .postgresql, .redshift: return .blue + case .sqlite: return .green + case .redis: return .red + case .mongodb: return .green + case .clickhouse: return .yellow + case .mssql: return .indigo + default: return .gray } } } diff --git a/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/TableProMobile/TableProMobile/Views/DataBrowserView.swift index 093327361..d206d6d31 100644 --- a/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -6,7 +6,6 @@ import SwiftUI import TableProDatabase import TableProModels -import TableProQuery struct DataBrowserView: View { let connection: DatabaseConnection @@ -16,14 +15,18 @@ struct DataBrowserView: View { @State private var columns: [ColumnInfo] = [] @State private var rows: [[String?]] = [] @State private var isLoading = true + @State private var isLoadingMore = false @State private var errorMessage: String? - @State private var selectedRow: IdentifiableRow? @State private var pagination = PaginationState(pageSize: 100, currentPage: 0) + @State private var hasMore = true + + private let maxPreviewColumns = 4 var body: some View { Group { if isLoading { ProgressView("Loading data...") + .frame(maxWidth: .infinity, maxHeight: .infinity) } else if let errorMessage { ContentUnavailableView { Label("Query Failed", systemImage: "exclamationmark.triangle") @@ -42,42 +45,63 @@ struct DataBrowserView: View { description: Text("This table is empty.") ) } else { - dataList + cardList } } .navigationTitle(table.name) .navigationBarTitleDisplayMode(.inline) - .task { await loadData() } - .sheet(item: $selectedRow) { row in - RowDetailView(columns: columns, row: row.values) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Text("\(rows.count) rows") + .font(.caption) + .foregroundStyle(.secondary) + } } + .task { await loadData() } } - private var dataList: some View { + private var cardList: some View { List { ForEach(Array(rows.enumerated()), id: \.offset) { index, row in - Button { - selectedRow = IdentifiableRow(values: row) + NavigationLink { + RowDetailView( + columns: columns, + rows: rows, + initialIndex: index + ) } label: { - RowSummaryView(columns: columns, row: row) + RowCard( + columns: columns, + row: row, + maxPreviewColumns: maxPreviewColumns + ) } - .foregroundStyle(.primary) } - if rows.count >= pagination.pageSize { - Button { - Task { await loadNextPage() } - } label: { - HStack { - Spacer() - Text("Load More") - .foregroundStyle(.blue) - Spacer() + if hasMore { + Section { + Button { + Task { await loadNextPage() } + } label: { + HStack { + Spacer() + if isLoadingMore { + ProgressView() + .controlSize(.small) + Text("Loading...") + } else { + Label("Load More", systemImage: "arrow.down.circle") + } + Spacer() + } + .foregroundStyle(.blue) } + .disabled(isLoadingMore) } } } .listStyle(.plain) + .refreshable { await loadData() } } private func loadData() async { @@ -89,12 +113,14 @@ struct DataBrowserView: View { isLoading = true errorMessage = nil + pagination.reset() do { let query = "SELECT * FROM \(table.name) LIMIT \(pagination.pageSize) OFFSET \(pagination.currentOffset)" let result = try await session.driver.execute(query: query) self.columns = result.columns self.rows = result.rows + self.hasMore = result.rows.count >= pagination.pageSize isLoading = false } catch { errorMessage = error.localizedDescription @@ -105,45 +131,52 @@ struct DataBrowserView: View { private func loadNextPage() async { guard let session else { return } + isLoadingMore = true pagination.currentPage += 1 + do { let query = "SELECT * FROM \(table.name) LIMIT \(pagination.pageSize) OFFSET \(pagination.currentOffset)" let result = try await session.driver.execute(query: query) rows.append(contentsOf: result.rows) + hasMore = result.rows.count >= pagination.pageSize } catch { pagination.currentPage -= 1 } - } -} -struct IdentifiableRow: Identifiable { - let id = UUID() - let values: [String?] + isLoadingMore = false + } } -struct RowSummaryView: View { +private struct RowCard: View { let columns: [ColumnInfo] let row: [String?] + let maxPreviewColumns: Int var body: some View { - VStack(alignment: .leading, spacing: 4) { - // Show first 3 columns as preview - ForEach(Array(zip(columns.prefix(3), row.prefix(3))), id: \.0.name) { col, value in - HStack(spacing: 6) { + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(zip(columns.prefix(maxPreviewColumns), row.prefix(maxPreviewColumns)).enumerated()), id: \.offset) { _, pair in + let (col, value) = pair + HStack(spacing: 8) { Text(col.name) .font(.caption) .foregroundStyle(.secondary) - .frame(width: 80, alignment: .trailing) - - Text(value ?? "NULL") - .font(.body) - .foregroundStyle(value == nil ? .secondary : .primary) - .lineLimit(1) + .frame(minWidth: 60, alignment: .leading) + + if let value { + Text(value) + .font(.subheadline) + .lineLimit(1) + } else { + Text("NULL") + .font(.subheadline) + .foregroundStyle(.secondary) + .italic() + } } } - if columns.count > 3 { - Text("+\(columns.count - 3) more columns") + if columns.count > maxPreviewColumns { + Text("+\(columns.count - maxPreviewColumns) more columns") .font(.caption2) .foregroundStyle(.tertiary) } diff --git a/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/TableProMobile/TableProMobile/Views/QueryEditorView.swift index 18bb1aaa6..04a521b01 100644 --- a/TableProMobile/TableProMobile/Views/QueryEditorView.swift +++ b/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -9,16 +9,23 @@ import TableProModels struct QueryEditorView: View { let session: ConnectionSession? + var tables: [TableInfo] = [] + var initialQuery: String = "" @State private var query = "" @State private var result: QueryResult? @State private var errorMessage: String? @State private var isExecuting = false @State private var executionTime: TimeInterval? + @State private var queryHistory: [String] = [] + @State private var showHistory = false + @State private var showTemplates = false + @FocusState private var editorFocused: Bool var body: some View { VStack(spacing: 0) { editorArea + keywordAccessory Divider() resultArea } @@ -30,56 +37,162 @@ struct QueryEditorView: View { Task { await executeQuery() } } label: { Image(systemName: isExecuting ? "stop.fill" : "play.fill") + .foregroundStyle(isExecuting ? .red : .green) } .disabled(query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isExecuting) } + + ToolbarItem(placement: .secondaryAction) { + Menu { + Button { + showHistory = true + } label: { + Label("History", systemImage: "clock") + } + .disabled(queryHistory.isEmpty) + + if !tables.isEmpty { + Menu { + ForEach(tables) { table in + Button(table.name) { + query = "SELECT * FROM \(table.name) LIMIT 100" + } + } + } label: { + Label("SELECT * FROM ...", systemImage: "text.badge.star") + } + } + + Divider() + + Button(role: .destructive) { + query = "" + result = nil + errorMessage = nil + executionTime = nil + } label: { + Label("Clear", systemImage: "trash") + } + } label: { + Image(systemName: "ellipsis.circle") + } + } + } + .onAppear { + if !initialQuery.isEmpty { + query = initialQuery + } + } + .sheet(isPresented: $showHistory) { + historySheet } } + // MARK: - Editor + private var editorArea: some View { VStack(spacing: 0) { TextEditor(text: $query) .font(.system(.body, design: .monospaced)) .autocorrectionDisabled() .textInputAutocapitalization(.never) - .frame(minHeight: 120, maxHeight: 200) + .scrollContentBackground(.hidden) + .frame(minHeight: 100, maxHeight: 180) .padding(.horizontal, 8) .padding(.vertical, 4) + .focused($editorFocused) - HStack { - if let time = executionTime { - Text(String(format: "%.2fms", time * 1000)) - .font(.caption) + if executionTime != nil || result != nil { + HStack { + if let time = executionTime { + Label( + String(format: "%.1fms", time * 1000), + systemImage: "clock" + ) + .font(.caption2) .foregroundStyle(.secondary) + } + Spacer() + if let result, !result.rows.isEmpty { + Text("\(result.rows.count) rows") + .font(.caption2) + .foregroundStyle(.secondary) + } } - Spacer() - if let result, !result.rows.isEmpty { - Text("\(result.rows.count) rows") + .padding(.horizontal, 12) + .padding(.bottom, 4) + } + } + } + + // MARK: - SQL Keyword Accessory + + private var keywordAccessory: some View { + ScrollView(.horizontal, showsIndicators: false) { + VStack(spacing: 6) { + keywordRow(["SELECT", "FROM", "WHERE", "AND", "OR", "JOIN"]) + keywordRow(["INSERT", "UPDATE", "DELETE", "CREATE", "DROP", "ALTER"]) + keywordRow(["LIMIT", "ORDER BY", "GROUP BY", "HAVING", "AS", "IN"]) + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + } + .background(.bar) + } + + private func keywordRow(_ keywords: [String]) -> some View { + HStack(spacing: 6) { + ForEach(keywords, id: \.self) { keyword in + Button { + insertKeyword(keyword) + } label: { + Text(keyword) .font(.caption) - .foregroundStyle(.secondary) + .fontWeight(.medium) + .fontDesign(.monospaced) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(.fill.secondary) + .clipShape(RoundedRectangle(cornerRadius: 6)) } + .buttonStyle(.plain) } - .padding(.horizontal, 12) - .padding(.bottom, 6) } } + private func insertKeyword(_ keyword: String) { + let needsLeadingSpace = !query.isEmpty && !query.hasSuffix(" ") && !query.hasSuffix("\n") + query += (needsLeadingSpace ? " " : "") + keyword + " " + editorFocused = true + } + + // MARK: - Results + private var resultArea: some View { Group { if isExecuting { - ProgressView("Executing...") - .frame(maxWidth: .infinity, maxHeight: .infinity) + VStack(spacing: 12) { + ProgressView() + Text("Executing...") + .font(.footnote) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) } else if let errorMessage { ScrollView { - Text(errorMessage) - .font(.system(.body, design: .monospaced)) - .foregroundStyle(.red) - .padding() - .frame(maxWidth: .infinity, alignment: .leading) + HStack(alignment: .top, spacing: 8) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.red) + Text(errorMessage) + .font(.system(.footnote, design: .monospaced)) + .foregroundStyle(.red) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) } } else if let result { if result.columns.isEmpty { - VStack { + VStack(spacing: 8) { Image(systemName: "checkmark.circle.fill") .font(.largeTitle) .foregroundStyle(.green) @@ -105,29 +218,80 @@ struct QueryEditorView: View { } private func resultTable(_ result: QueryResult) -> some View { - List { - ForEach(Array(result.rows.enumerated()), id: \.offset) { _, row in - VStack(alignment: .leading, spacing: 4) { - ForEach(Array(zip(result.columns, row)), id: \.0.name) { col, value in - HStack(spacing: 6) { + ScrollView(.horizontal) { + LazyVStack(alignment: .leading, spacing: 0, pinnedViews: .sectionHeaders) { + Section { + ForEach(Array(result.rows.enumerated()), id: \.offset) { _, row in + HStack(spacing: 0) { + ForEach(Array(zip(result.columns, row).enumerated()), id: \.offset) { _, pair in + let (_, value) = pair + Text(value ?? "NULL") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(value == nil ? .secondary : .primary) + .lineLimit(1) + .frame(width: 140, alignment: .leading) + .padding(.horizontal, 8) + .padding(.vertical, 8) + } + } + Divider() + } + } header: { + HStack(spacing: 0) { + ForEach(result.columns) { col in Text(col.name) - .font(.caption) - .foregroundStyle(.secondary) - .frame(width: 100, alignment: .trailing) - - Text(value ?? "NULL") - .font(.system(.body, design: .monospaced)) - .foregroundStyle(value == nil ? .secondary : .primary) - .lineLimit(2) + .font(.system(.caption, design: .monospaced)) + .fontWeight(.semibold) + .frame(width: 140, alignment: .leading) + .padding(.horizontal, 8) + .padding(.vertical, 8) } } + .background(.bar) + Divider() + } + } + } + } + + // MARK: - History + + private var historySheet: some View { + NavigationStack { + List { + ForEach(queryHistory.reversed(), id: \.self) { historyQuery in + Button { + query = historyQuery + showHistory = false + } label: { + Text(historyQuery) + .font(.system(.footnote, design: .monospaced)) + .lineLimit(3) + .foregroundStyle(.primary) + } + } + } + .navigationTitle("Query History") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { showHistory = false } + } + } + .overlay { + if queryHistory.isEmpty { + ContentUnavailableView( + "No History", + systemImage: "clock", + description: Text("Executed queries will appear here.") + ) } - .padding(.vertical, 4) } } - .listStyle(.plain) } + // MARK: - Execution + private func executeQuery() async { guard let session else { return } let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) @@ -141,6 +305,13 @@ struct QueryEditorView: View { let queryResult = try await session.driver.execute(query: trimmed) self.result = queryResult self.executionTime = queryResult.executionTime + + if !queryHistory.contains(trimmed) { + queryHistory.append(trimmed) + if queryHistory.count > 50 { + queryHistory.removeFirst() + } + } } catch { self.errorMessage = error.localizedDescription } diff --git a/TableProMobile/TableProMobile/Views/RowDetailView.swift b/TableProMobile/TableProMobile/Views/RowDetailView.swift index dc97036f2..56b97678f 100644 --- a/TableProMobile/TableProMobile/Views/RowDetailView.swift +++ b/TableProMobile/TableProMobile/Views/RowDetailView.swift @@ -7,55 +7,105 @@ import SwiftUI import TableProModels struct RowDetailView: View { - @Environment(\.dismiss) private var dismiss let columns: [ColumnInfo] - let row: [String?] + let rows: [[String?]] + @State private var currentIndex: Int - var body: some View { - NavigationStack { - List { - ForEach(Array(zip(columns, row)), id: \.0.name) { column, value in - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(column.name) - .font(.caption) - .fontWeight(.semibold) - .foregroundStyle(.secondary) - - Spacer() - - Text(column.typeName) - .font(.caption2) - .foregroundStyle(.tertiary) + init(columns: [ColumnInfo], rows: [[String?]], initialIndex: Int) { + self.columns = columns + self.rows = rows + _currentIndex = State(initialValue: initialIndex) + } + + private var currentRow: [String?] { + guard currentIndex >= 0, currentIndex < rows.count else { return [] } + return rows[currentIndex] + } - if column.isPrimaryKey { - Image(systemName: "key.fill") - .font(.caption2) - .foregroundStyle(.orange) + var body: some View { + List { + ForEach(Array(zip(columns, currentRow).enumerated()), id: \.offset) { _, pair in + let (column, value) = pair + Section { + fieldContent(value: value) + .contextMenu { + if let value { + Button { + UIPasteboard.general.string = value + } label: { + Label("Copy Value", systemImage: "doc.on.doc") + } + } + Button { + UIPasteboard.general.string = column.name + } label: { + Label("Copy Column Name", systemImage: "textformat") } } - - if let value { - Text(value) - .font(.body) - .textSelection(.enabled) - } else { - Text("NULL") - .font(.body) - .foregroundStyle(.secondary) - .italic() + } header: { + HStack(spacing: 6) { + if column.isPrimaryKey { + Image(systemName: "key.fill") + .font(.caption2) + .foregroundStyle(.orange) } + Text(column.name) + + Spacer() + + Text(column.typeName) + .font(.caption2) + .fontWeight(.medium) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.fill.tertiary) + .clipShape(Capsule()) } - .padding(.vertical, 4) } } - .navigationTitle("Row Detail") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("Done") { dismiss() } + } + .listStyle(.insetGrouped) + .navigationTitle("Row \(currentIndex + 1) of \(rows.count)") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .bottomBar) { + Button { + withAnimation { currentIndex -= 1 } + } label: { + Image(systemName: "chevron.left") + } + .disabled(currentIndex <= 0) + + Spacer() + + Text("\(currentIndex + 1) / \(rows.count)") + .font(.footnote) + .foregroundStyle(.secondary) + .monospacedDigit() + + Spacer() + + Button { + withAnimation { currentIndex += 1 } + } label: { + Image(systemName: "chevron.right") } + .disabled(currentIndex >= rows.count - 1) } } } + + @ViewBuilder + private func fieldContent(value: String?) -> some View { + if let value { + Text(value) + .font(.body) + .textSelection(.enabled) + } else { + Text("NULL") + .font(.body) + .foregroundStyle(.secondary) + .italic() + } + } } diff --git a/TableProMobile/TableProMobile/Views/TableListView.swift b/TableProMobile/TableProMobile/Views/TableListView.swift index cfb70bc01..f8ca69995 100644 --- a/TableProMobile/TableProMobile/Views/TableListView.swift +++ b/TableProMobile/TableProMobile/Views/TableListView.swift @@ -11,32 +11,62 @@ struct TableListView: View { let connection: DatabaseConnection let tables: [TableInfo] let session: ConnectionSession? + var onRefresh: (() async -> Void)? @State private var searchText = "" private var filteredTables: [TableInfo] { - if searchText.isEmpty { return tables } - return tables.filter { $0.name.localizedCaseInsensitiveContains(searchText) } + let filtered = searchText.isEmpty ? tables : tables.filter { + $0.name.localizedCaseInsensitiveContains(searchText) + } + return filtered + } + + private var tableSections: [(String, [TableInfo])] { + let tableItems = filteredTables.filter { $0.type == .table || $0.type == .systemTable } + let viewItems = filteredTables.filter { $0.type == .view || $0.type == .materializedView } + + var sections: [(String, [TableInfo])] = [] + if !tableItems.isEmpty { + sections.append(("Tables", tableItems)) + } + if !viewItems.isEmpty { + sections.append(("Views", viewItems)) + } + return sections } var body: some View { List { - ForEach(filteredTables) { table in - NavigationLink(value: table) { - TableRow(table: table) + ForEach(tableSections, id: \.0) { sectionTitle, items in + Section { + ForEach(items) { table in + NavigationLink(value: table) { + TableRow(table: table) + } + .swipeActions(edge: .leading) { + NavigationLink(value: QuickQuery(table: table)) { + Label("Query", systemImage: "terminal") + } + .tint(.blue) + } + } + } header: { + HStack { + Text(sectionTitle) + Spacer() + Text("\(items.count)") + .font(.caption) + .foregroundStyle(.tertiary) + } } } } + .listStyle(.insetGrouped) .searchable(text: $searchText, prompt: "Search tables") .navigationTitle("Tables") - .toolbar { - ToolbarItem(placement: .primaryAction) { - NavigationLink { - QueryEditorView(session: session) - } label: { - Image(systemName: "terminal") - } - } + .refreshable { + await onRefresh?() } .navigationDestination(for: TableInfo.self) { table in DataBrowserView( @@ -45,6 +75,13 @@ struct TableListView: View { session: session ) } + .navigationDestination(for: QuickQuery.self) { query in + QueryEditorView( + session: session, + tables: tables, + initialQuery: "SELECT * FROM \(query.table.name) LIMIT 100" + ) + } .overlay { if filteredTables.isEmpty && !searchText.isEmpty { ContentUnavailableView.search(text: searchText) @@ -59,26 +96,42 @@ struct TableListView: View { } } -struct TableRow: View { +struct QuickQuery: Hashable { + let table: TableInfo +} + +private struct TableRow: View { let table: TableInfo var body: some View { HStack { - Image(systemName: table.type == .view ? "eye" : "tablecells") + Image(systemName: table.type == .view || table.type == .materializedView ? "eye" : "tablecells") .foregroundStyle(.secondary) .frame(width: 24) - VStack(alignment: .leading, spacing: 2) { - Text(table.name) - .font(.body) + Text(table.name) + .font(.body) - if let rowCount = table.rowCount { - Text("\(rowCount) rows") - .font(.caption) - .foregroundStyle(.secondary) - } + Spacer() + + if let rowCount = table.rowCount { + Text(formatRowCount(rowCount)) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(.fill.tertiary) + .clipShape(Capsule()) } } } -} + private func formatRowCount(_ count: Int) -> String { + if count >= 1_000_000 { + return String(format: "%.1fM", Double(count) / 1_000_000) + } else if count >= 1000 { + return String(format: "%.1fK", Double(count) / 1000) + } + return "\(count)" + } +} From 865e30add23d0a9a84647a02f6baf96f7c646067 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 1 Apr 2026 00:22:45 +0700 Subject: [PATCH 09/61] fix: add Hashable to DatabaseConnection and SSH/SSL types for NavigationLink --- .../Sources/TableProModels/DatabaseConnection.swift | 2 +- .../Sources/TableProModels/SSHConfiguration.swift | 4 ++-- .../Sources/TableProModels/SSLConfiguration.swift | 2 +- .../TableProMobile/Views/ConnectionListView.swift | 9 --------- 4 files changed, 4 insertions(+), 13 deletions(-) diff --git a/Packages/TableProCore/Sources/TableProModels/DatabaseConnection.swift b/Packages/TableProCore/Sources/TableProModels/DatabaseConnection.swift index 086d01f0f..d8ab6a6b1 100644 --- a/Packages/TableProCore/Sources/TableProModels/DatabaseConnection.swift +++ b/Packages/TableProCore/Sources/TableProModels/DatabaseConnection.swift @@ -1,6 +1,6 @@ import Foundation -public struct DatabaseConnection: Identifiable, Codable, Sendable { +public struct DatabaseConnection: Identifiable, Codable, Hashable, Sendable { public var id: UUID public var name: String public var type: DatabaseType diff --git a/Packages/TableProCore/Sources/TableProModels/SSHConfiguration.swift b/Packages/TableProCore/Sources/TableProModels/SSHConfiguration.swift index 76474bee1..ac718bb19 100644 --- a/Packages/TableProCore/Sources/TableProModels/SSHConfiguration.swift +++ b/Packages/TableProCore/Sources/TableProModels/SSHConfiguration.swift @@ -1,6 +1,6 @@ import Foundation -public struct SSHConfiguration: Codable, Sendable { +public struct SSHConfiguration: Codable, Hashable, Sendable { public var host: String public var port: Int public var username: String @@ -31,7 +31,7 @@ public struct SSHConfiguration: Codable, Sendable { } } -public struct SSHJumpHost: Codable, Sendable, Identifiable { +public struct SSHJumpHost: Codable, Hashable, Sendable, Identifiable { public var id: UUID public var host: String public var port: Int diff --git a/Packages/TableProCore/Sources/TableProModels/SSLConfiguration.swift b/Packages/TableProCore/Sources/TableProModels/SSLConfiguration.swift index f47fa1ac3..ec1568254 100644 --- a/Packages/TableProCore/Sources/TableProModels/SSLConfiguration.swift +++ b/Packages/TableProCore/Sources/TableProModels/SSLConfiguration.swift @@ -1,6 +1,6 @@ import Foundation -public struct SSLConfiguration: Codable, Sendable { +public struct SSLConfiguration: Codable, Hashable, Sendable { public var mode: SSLMode public var caCertificatePath: String? public var clientCertificatePath: String? diff --git a/TableProMobile/TableProMobile/Views/ConnectionListView.swift b/TableProMobile/TableProMobile/Views/ConnectionListView.swift index b967b06fe..52531f254 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionListView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionListView.swift @@ -177,12 +177,3 @@ private struct ConnectionRow: View { } } -extension DatabaseConnection: @retroactive Hashable { - public func hash(into hasher: inout Hasher) { - hasher.combine(id) - } - - public static func == (lhs: DatabaseConnection, rhs: DatabaseConnection) -> Bool { - lhs.id == rhs.id - } -} From b63762d12ceba39ab8c1040d3f3cb4b26d5a49d0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 1 Apr 2026 00:24:40 +0700 Subject: [PATCH 10/61] fix: replace nested NavigationStack/TabView with segmented Picker in ConnectedView --- .../TableProMobile/Views/ConnectedView.swift | 77 ++++++++----------- 1 file changed, 32 insertions(+), 45 deletions(-) diff --git a/TableProMobile/TableProMobile/Views/ConnectedView.swift b/TableProMobile/TableProMobile/Views/ConnectedView.swift index d887b3844..0edcf970e 100644 --- a/TableProMobile/TableProMobile/Views/ConnectedView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectedView.swift @@ -15,7 +15,12 @@ struct ConnectedView: View { @State private var tables: [TableInfo] = [] @State private var isConnecting = true @State private var errorMessage: String? - @State private var selectedTab = 0 + @State private var selectedTab = ConnectedTab.tables + + enum ConnectedTab: String, CaseIterable { + case tables = "Tables" + case query = "Query" + } private var displayName: String { connection.name.isEmpty ? connection.host : connection.name @@ -38,65 +43,47 @@ struct ConnectedView: View { .buttonStyle(.borderedProminent) } } else { - connectedTabs + connectedContent } } - .toolbar(session != nil && errorMessage == nil ? .hidden : .visible, for: .navigationBar) + .navigationTitle(displayName) + .navigationBarTitleDisplayMode(.inline) .task { await connect() } .onDisappear { - Task { - if let session { - try? await session.driver.disconnect() - } + if let session { + Task { try? await session.driver.disconnect() } } } } - private var connectedTabs: some View { - TabView(selection: $selectedTab) { - Tab("Tables", systemImage: "tablecells", value: 0) { - NavigationStack { - TableListView( - connection: connection, - tables: tables, - session: session, - onRefresh: { await refreshTables() } - ) - .toolbar { - ToolbarItem(placement: .status) { - connectionStatusBadge - } - } + private var connectedContent: some View { + VStack(spacing: 0) { + Picker("Tab", selection: $selectedTab) { + ForEach(ConnectedTab.allCases, id: \.self) { tab in + Text(tab.rawValue).tag(tab) } } + .pickerStyle(.segmented) + .padding(.horizontal) + .padding(.vertical, 8) - Tab("Query", systemImage: "terminal", value: 1) { - NavigationStack { - QueryEditorView( - session: session, - tables: tables - ) - .toolbar { - ToolbarItem(placement: .status) { - connectionStatusBadge - } - } - } + switch selectedTab { + case .tables: + TableListView( + connection: connection, + tables: tables, + session: session, + onRefresh: { await refreshTables() } + ) + case .query: + QueryEditorView( + session: session, + tables: tables + ) } } } - private var connectionStatusBadge: some View { - HStack(spacing: 4) { - Circle() - .fill(.green) - .frame(width: 6, height: 6) - Text(displayName) - .font(.caption2) - .foregroundStyle(.secondary) - } - } - private func connect() async { isConnecting = true errorMessage = nil From ddbed80eccf2c863250c81a8e14df5f23f6c4c27 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 1 Apr 2026 00:30:57 +0700 Subject: [PATCH 11/61] feat: persist connections to UserDefaults across app restarts --- TableProMobile/TableProMobile/AppState.swift | 32 ++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/TableProMobile/TableProMobile/AppState.swift b/TableProMobile/TableProMobile/AppState.swift index 5b4286a4d..aea04a3fd 100644 --- a/TableProMobile/TableProMobile/AppState.swift +++ b/TableProMobile/TableProMobile/AppState.swift @@ -13,6 +13,8 @@ final class AppState { var connections: [DatabaseConnection] = [] let connectionManager: ConnectionManager + private let storage = ConnectionPersistence() + init() { let driverFactory = IOSDriverFactory() let secureStore = KeychainSecureStore() @@ -20,13 +22,43 @@ final class AppState { driverFactory: driverFactory, secureStore: secureStore ) + connections = storage.load() } func addConnection(_ connection: DatabaseConnection) { connections.append(connection) + storage.save(connections) + } + + func updateConnection(_ connection: DatabaseConnection) { + if let index = connections.firstIndex(where: { $0.id == connection.id }) { + connections[index] = connection + storage.save(connections) + } } func removeConnection(_ connection: DatabaseConnection) { connections.removeAll { $0.id == connection.id } + try? connectionManager.deletePassword(for: connection.id) + storage.save(connections) + } +} + +// MARK: - Persistence + +private struct ConnectionPersistence { + private let key = "com.TablePro.Mobile.connections" + + func save(_ connections: [DatabaseConnection]) { + guard let data = try? JSONEncoder().encode(connections) else { return } + UserDefaults.standard.set(data, forKey: key) + } + + func load() -> [DatabaseConnection] { + guard let data = UserDefaults.standard.data(forKey: key), + let connections = try? JSONDecoder().decode([DatabaseConnection].self, from: data) else { + return [] + } + return connections } } From bb2dd201e0aa95817e60c52806ee0a1eaf4eda25 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 1 Apr 2026 00:34:11 +0700 Subject: [PATCH 12/61] fix: use ASCII keyboard for query editor to prevent smart quote substitution --- TableProMobile/TableProMobile/Views/QueryEditorView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/TableProMobile/TableProMobile/Views/QueryEditorView.swift index 04a521b01..8cbe8ac3f 100644 --- a/TableProMobile/TableProMobile/Views/QueryEditorView.swift +++ b/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -96,6 +96,7 @@ struct QueryEditorView: View { .font(.system(.body, design: .monospaced)) .autocorrectionDisabled() .textInputAutocapitalization(.never) + .keyboardType(.asciiCapable) .scrollContentBackground(.hidden) .frame(minHeight: 100, maxHeight: 180) .padding(.horizontal, 8) From 9e14e504ac84165c3624764fb50290342fac2b2e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 1 Apr 2026 00:38:11 +0700 Subject: [PATCH 13/61] =?UTF-8?q?refactor:=20polish=20iOS=20views=20?= =?UTF-8?q?=E2=80=94=20compact=20keyword=20bar,=20dynamic=20column=20width?= =?UTF-8?q?s,=20PK-first=20cards,=20swipe=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Views/ConnectionListView.swift | 2 +- .../Views/DataBrowserView.swift | 16 +++-- .../Views/QueryEditorView.swift | 70 +++++++++++-------- .../TableProMobile/Views/RowDetailView.swift | 10 +++ 4 files changed, 65 insertions(+), 33 deletions(-) diff --git a/TableProMobile/TableProMobile/Views/ConnectionListView.swift b/TableProMobile/TableProMobile/Views/ConnectionListView.swift index 52531f254..be94c1ace 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionListView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionListView.swift @@ -153,7 +153,7 @@ private struct ConnectionRow: View { private func iconName(for type: DatabaseType) -> String { switch type { case .mysql, .mariadb: return "cylinder" - case .postgresql, .redshift: return "elephant" + case .postgresql, .redshift: return "cylinder.split.1x2" case .sqlite: return "doc" case .redis: return "key" case .mongodb: return "leaf" diff --git a/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/TableProMobile/TableProMobile/Views/DataBrowserView.swift index d206d6d31..3ee955aba 100644 --- a/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -76,6 +76,7 @@ struct DataBrowserView: View { maxPreviewColumns: maxPreviewColumns ) } + .listRowBackground(Color(.secondarySystemGroupedBackground)) } if hasMore { @@ -152,19 +153,26 @@ private struct RowCard: View { let row: [String?] let maxPreviewColumns: Int + private var sortedPairs: [(column: ColumnInfo, value: String?)] { + let paired = zip(columns, row).map { ($0, $1) } + let pkPairs = paired.filter { $0.0.isPrimaryKey } + let nonPkPairs = paired.filter { !$0.0.isPrimaryKey } + return (pkPairs + nonPkPairs).prefix(maxPreviewColumns).map { ($0.0, $0.1) } + } + var body: some View { VStack(alignment: .leading, spacing: 6) { - ForEach(Array(zip(columns.prefix(maxPreviewColumns), row.prefix(maxPreviewColumns)).enumerated()), id: \.offset) { _, pair in - let (col, value) = pair + ForEach(Array(sortedPairs.enumerated()), id: \.offset) { _, pair in HStack(spacing: 8) { - Text(col.name) + Text(pair.column.name) .font(.caption) .foregroundStyle(.secondary) .frame(minWidth: 60, alignment: .leading) - if let value { + if let value = pair.value { Text(value) .font(.subheadline) + .fontWeight(pair.column.isPrimaryKey ? .semibold : .regular) .lineLimit(1) } else { Text("NULL") diff --git a/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/TableProMobile/TableProMobile/Views/QueryEditorView.swift index 8cbe8ac3f..13120490f 100644 --- a/TableProMobile/TableProMobile/Views/QueryEditorView.swift +++ b/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -130,10 +130,20 @@ struct QueryEditorView: View { private var keywordAccessory: some View { ScrollView(.horizontal, showsIndicators: false) { - VStack(spacing: 6) { - keywordRow(["SELECT", "FROM", "WHERE", "AND", "OR", "JOIN"]) - keywordRow(["INSERT", "UPDATE", "DELETE", "CREATE", "DROP", "ALTER"]) - keywordRow(["LIMIT", "ORDER BY", "GROUP BY", "HAVING", "AS", "IN"]) + HStack(spacing: 6) { + ForEach(keywords, id: \.self) { keyword in + Button { insertKeyword(keyword) } label: { + Text(keyword) + .font(.caption2) + .fontWeight(.medium) + .fontDesign(.monospaced) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(.fill.secondary) + .clipShape(RoundedRectangle(cornerRadius: 5)) + } + .buttonStyle(.plain) + } } .padding(.horizontal, 8) .padding(.vertical, 6) @@ -141,25 +151,13 @@ struct QueryEditorView: View { .background(.bar) } - private func keywordRow(_ keywords: [String]) -> some View { - HStack(spacing: 6) { - ForEach(keywords, id: \.self) { keyword in - Button { - insertKeyword(keyword) - } label: { - Text(keyword) - .font(.caption) - .fontWeight(.medium) - .fontDesign(.monospaced) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(.fill.secondary) - .clipShape(RoundedRectangle(cornerRadius: 6)) - } - .buttonStyle(.plain) - } - } - } + private let keywords = [ + "SELECT", "FROM", "WHERE", "AND", "OR", "JOIN", + "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", + "CREATE", "TABLE", "DROP", "ALTER", + "LIMIT", "ORDER BY", "GROUP BY", "HAVING", "AS", "IN", + "NOT", "NULL", "LIKE", "BETWEEN", "COUNT", "DISTINCT" + ] private func insertKeyword(_ keyword: String) { let needsLeadingSpace = !query.isEmpty && !query.hasSuffix(" ") && !query.hasSuffix("\n") @@ -224,26 +222,33 @@ struct QueryEditorView: View { Section { ForEach(Array(result.rows.enumerated()), id: \.offset) { _, row in HStack(spacing: 0) { - ForEach(Array(zip(result.columns, row).enumerated()), id: \.offset) { _, pair in - let (_, value) = pair + ForEach(Array(result.columns.enumerated()), id: \.offset) { colIndex, column in + let value = colIndex < row.count ? row[colIndex] : nil Text(value ?? "NULL") .font(.system(.caption, design: .monospaced)) .foregroundStyle(value == nil ? .secondary : .primary) .lineLimit(1) - .frame(width: 140, alignment: .leading) + .frame(width: columnWidth(for: colIndex, column: column, rows: result.rows), alignment: .leading) .padding(.horizontal, 8) .padding(.vertical, 8) + .contextMenu { + Button { + UIPasteboard.general.string = value ?? "" + } label: { + Label("Copy", systemImage: "doc.on.doc") + } + } } } Divider() } } header: { HStack(spacing: 0) { - ForEach(result.columns) { col in + ForEach(Array(result.columns.enumerated()), id: \.offset) { colIndex, col in Text(col.name) .font(.system(.caption, design: .monospaced)) .fontWeight(.semibold) - .frame(width: 140, alignment: .leading) + .frame(width: columnWidth(for: colIndex, column: col, rows: result.rows), alignment: .leading) .padding(.horizontal, 8) .padding(.vertical, 8) } @@ -255,6 +260,15 @@ struct QueryEditorView: View { } } + private func columnWidth(for columnIndex: Int, column: ColumnInfo, rows: [[String?]]) -> CGFloat { + let headerWidth = CGFloat(column.name.count) * 8 + 16 + let maxDataWidth = rows.prefix(20).compactMap { row -> CGFloat? in + guard columnIndex < row.count, let value = row[columnIndex] else { return nil } + return min(CGFloat(value.count) * 7.5, 200) + 16 + }.max() ?? 60 + return max(max(headerWidth, maxDataWidth), 60) + } + // MARK: - History private var historySheet: some View { diff --git a/TableProMobile/TableProMobile/Views/RowDetailView.swift b/TableProMobile/TableProMobile/Views/RowDetailView.swift index 56b97678f..588d471b7 100644 --- a/TableProMobile/TableProMobile/Views/RowDetailView.swift +++ b/TableProMobile/TableProMobile/Views/RowDetailView.swift @@ -67,6 +67,16 @@ struct RowDetailView: View { .listStyle(.insetGrouped) .navigationTitle("Row \(currentIndex + 1) of \(rows.count)") .navigationBarTitleDisplayMode(.inline) + .gesture( + DragGesture(minimumDistance: 50) + .onEnded { value in + if value.translation.width > 50, currentIndex > 0 { + withAnimation { currentIndex -= 1 } + } else if value.translation.width < -50, currentIndex < rows.count - 1 { + withAnimation { currentIndex += 1 } + } + } + ) .toolbar { ToolbarItemGroup(placement: .bottomBar) { Button { From da1f799bc5c5a7dbc3a104fca9f6946b24c82f4d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 1 Apr 2026 00:39:34 +0700 Subject: [PATCH 14/61] fix: disconnect only when leaving connection, not on push navigation --- .../TableProMobile/Views/ConnectedView.swift | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/TableProMobile/TableProMobile/Views/ConnectedView.swift b/TableProMobile/TableProMobile/Views/ConnectedView.swift index 0edcf970e..eaa15bdca 100644 --- a/TableProMobile/TableProMobile/Views/ConnectedView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectedView.swift @@ -48,10 +48,15 @@ struct ConnectedView: View { } .navigationTitle(displayName) .navigationBarTitleDisplayMode(.inline) - .task { await connect() } - .onDisappear { + .task { + await connect() + } + .task(id: "lifecycle") { + // Wait for task cancellation (view removed from navigation stack) + // then disconnect. This avoids false onDisappear during push navigation. + do { try await Task.sleep(for: .seconds(.max)) } catch {} if let session { - Task { try? await session.driver.disconnect() } + try? await session.driver.disconnect() } } } From 86a646e9562153935b40b6b1d5abf7babc13a52a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 1 Apr 2026 00:40:09 +0700 Subject: [PATCH 15/61] fix: use Double.greatestFiniteMagnitude for task sleep --- TableProMobile/TableProMobile/Views/ConnectedView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TableProMobile/TableProMobile/Views/ConnectedView.swift b/TableProMobile/TableProMobile/Views/ConnectedView.swift index eaa15bdca..af29e801d 100644 --- a/TableProMobile/TableProMobile/Views/ConnectedView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectedView.swift @@ -54,7 +54,7 @@ struct ConnectedView: View { .task(id: "lifecycle") { // Wait for task cancellation (view removed from navigation stack) // then disconnect. This avoids false onDisappear during push navigation. - do { try await Task.sleep(for: .seconds(.max)) } catch {} + do { try await Task.sleep(for: .seconds(Double.greatestFiniteMagnitude)) } catch {} if let session { try? await session.driver.disconnect() } From 1b8acabe9ab3c17d6c481eb026900912032f95c9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 1 Apr 2026 00:40:54 +0700 Subject: [PATCH 16/61] fix: use UInt64.max nanoseconds to avoid Duration overflow crash --- TableProMobile/TableProMobile/Views/ConnectedView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TableProMobile/TableProMobile/Views/ConnectedView.swift b/TableProMobile/TableProMobile/Views/ConnectedView.swift index af29e801d..de2acc2b3 100644 --- a/TableProMobile/TableProMobile/Views/ConnectedView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectedView.swift @@ -54,7 +54,7 @@ struct ConnectedView: View { .task(id: "lifecycle") { // Wait for task cancellation (view removed from navigation stack) // then disconnect. This avoids false onDisappear during push navigation. - do { try await Task.sleep(for: .seconds(Double.greatestFiniteMagnitude)) } catch {} + do { try await Task.sleep(nanoseconds: UInt64.max) } catch {} if let session { try? await session.driver.disconnect() } From 9c421ffd56a3aef30b4a6f997966fac5ac1b7eba Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 1 Apr 2026 00:42:16 +0700 Subject: [PATCH 17/61] fix: remove auto-disconnect from ConnectedView to prevent connection loss during navigation --- .../TableProMobile/Views/ConnectedView.swift | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/TableProMobile/TableProMobile/Views/ConnectedView.swift b/TableProMobile/TableProMobile/Views/ConnectedView.swift index de2acc2b3..c9259df31 100644 --- a/TableProMobile/TableProMobile/Views/ConnectedView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectedView.swift @@ -48,17 +48,7 @@ struct ConnectedView: View { } .navigationTitle(displayName) .navigationBarTitleDisplayMode(.inline) - .task { - await connect() - } - .task(id: "lifecycle") { - // Wait for task cancellation (view removed from navigation stack) - // then disconnect. This avoids false onDisappear during push navigation. - do { try await Task.sleep(nanoseconds: UInt64.max) } catch {} - if let session { - try? await session.driver.disconnect() - } - } + .task { await connect() } } private var connectedContent: some View { From df269cabd85f308b8ad2ff53ada778c2eb9da5ae Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 1 Apr 2026 00:45:20 +0700 Subject: [PATCH 18/61] fix: remove DragGesture from RowDetailView to avoid conflict with back gesture and list scroll --- .../TableProMobile/Views/RowDetailView.swift | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/TableProMobile/TableProMobile/Views/RowDetailView.swift b/TableProMobile/TableProMobile/Views/RowDetailView.swift index 588d471b7..56b97678f 100644 --- a/TableProMobile/TableProMobile/Views/RowDetailView.swift +++ b/TableProMobile/TableProMobile/Views/RowDetailView.swift @@ -67,16 +67,6 @@ struct RowDetailView: View { .listStyle(.insetGrouped) .navigationTitle("Row \(currentIndex + 1) of \(rows.count)") .navigationBarTitleDisplayMode(.inline) - .gesture( - DragGesture(minimumDistance: 50) - .onEnded { value in - if value.translation.width > 50, currentIndex > 0 { - withAnimation { currentIndex -= 1 } - } else if value.translation.width < -50, currentIndex < rows.count - 1 { - withAnimation { currentIndex += 1 } - } - } - ) .toolbar { ToolbarItemGroup(placement: .bottomBar) { Button { From dbaeea3d2ab39043999139eaaebdd6e191efeca0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 1 Apr 2026 00:46:49 +0700 Subject: [PATCH 19/61] feat: disconnect all database sessions when app enters background --- .../TableProDatabase/ConnectionManager.swift | 13 +++++++++++++ TableProMobile/TableProMobile/AppState.swift | 6 ++++++ .../TableProMobile/TableProMobileApp.swift | 11 +++++++++++ 3 files changed, 30 insertions(+) diff --git a/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift b/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift index c97f712a1..c9ee10fa9 100644 --- a/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift +++ b/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift @@ -78,6 +78,19 @@ public final class ConnectionManager: @unchecked Sendable { } } + public func disconnectAll() async { + let ids = allSessionIds() + for id in ids { + await disconnect(id) + } + } + + private func allSessionIds() -> [UUID] { + lock.lock() + defer { lock.unlock() } + return Array(sessions.keys) + } + public func updateSession(_ connectionId: UUID, _ mutation: (inout ConnectionSession) -> Void) { lock.lock() defer { lock.unlock() } diff --git a/TableProMobile/TableProMobile/AppState.swift b/TableProMobile/TableProMobile/AppState.swift index aea04a3fd..2d512b117 100644 --- a/TableProMobile/TableProMobile/AppState.swift +++ b/TableProMobile/TableProMobile/AppState.swift @@ -42,6 +42,12 @@ final class AppState { try? connectionManager.deletePassword(for: connection.id) storage.save(connections) } + + func disconnectAll() { + Task { + await connectionManager.disconnectAll() + } + } } // MARK: - Persistence diff --git a/TableProMobile/TableProMobile/TableProMobileApp.swift b/TableProMobile/TableProMobile/TableProMobileApp.swift index de0d89a09..78b986200 100644 --- a/TableProMobile/TableProMobile/TableProMobileApp.swift +++ b/TableProMobile/TableProMobile/TableProMobileApp.swift @@ -10,11 +10,22 @@ import TableProModels @main struct TableProMobileApp: App { @State private var appState = AppState() + @Environment(\.scenePhase) private var scenePhase var body: some Scene { WindowGroup { ConnectionListView() .environment(appState) } + .onChange(of: scenePhase) { _, phase in + switch phase { + case .background: + appState.disconnectAll() + case .active: + break + default: + break + } + } } } From eb76fd039b0609d0e1543e09b72ade3706f8b853 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 1 Apr 2026 00:52:39 +0700 Subject: [PATCH 20/61] =?UTF-8?q?feat:=20add=20row=20editing=20=E2=80=94?= =?UTF-8?q?=20swipe-to-delete,=20edit=20mode,=20insert=20row=20for=20iOS?= =?UTF-8?q?=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TableProMobile/Helpers/SQLHelper.swift | 48 ++++ .../Views/DataBrowserView.swift | 114 ++++++++- .../TableProMobile/Views/InsertRowView.swift | 202 ++++++++++++++++ .../TableProMobile/Views/RowDetailView.swift | 226 ++++++++++++++++-- 4 files changed, 568 insertions(+), 22 deletions(-) create mode 100644 TableProMobile/TableProMobile/Helpers/SQLHelper.swift create mode 100644 TableProMobile/TableProMobile/Views/InsertRowView.swift diff --git a/TableProMobile/TableProMobile/Helpers/SQLHelper.swift b/TableProMobile/TableProMobile/Helpers/SQLHelper.swift new file mode 100644 index 000000000..2c8164869 --- /dev/null +++ b/TableProMobile/TableProMobile/Helpers/SQLHelper.swift @@ -0,0 +1,48 @@ +// +// SQLHelper.swift +// TableProMobile +// + +import Foundation + +enum SQLHelper { + static func buildDelete( + table: String, + primaryKeys: [(column: String, value: String)] + ) -> String { + let whereClause = primaryKeys.map { "`\($0.column)` = '\(escape($0.value))'" } + .joined(separator: " AND ") + return "DELETE FROM `\(table)` WHERE \(whereClause)" + } + + static func buildUpdate( + table: String, + changes: [(column: String, value: String?)], + primaryKeys: [(column: String, value: String)] + ) -> String { + let setClauses = changes.map { col, val in + if let val { return "`\(col)` = '\(escape(val))'" } + return "`\(col)` = NULL" + }.joined(separator: ", ") + let whereClause = primaryKeys.map { "`\($0.column)` = '\(escape($0.value))'" } + .joined(separator: " AND ") + return "UPDATE `\(table)` SET \(setClauses) WHERE \(whereClause)" + } + + static func buildInsert( + table: String, + columns: [String], + values: [String?] + ) -> String { + let cols = columns.map { "`\($0)`" }.joined(separator: ", ") + let vals = values.map { val in + if let val { return "'\(escape(val))'" } + return "NULL" + }.joined(separator: ", ") + return "INSERT INTO `\(table)` (\(cols)) VALUES (\(vals))" + } + + static func escape(_ value: String) -> String { + value.replacingOccurrences(of: "'", with: "''") + } +} diff --git a/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/TableProMobile/TableProMobile/Views/DataBrowserView.swift index 3ee955aba..e59781159 100644 --- a/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -13,15 +13,29 @@ struct DataBrowserView: View { let session: ConnectionSession? @State private var columns: [ColumnInfo] = [] + @State private var columnDetails: [ColumnInfo] = [] @State private var rows: [[String?]] = [] @State private var isLoading = true @State private var isLoadingMore = false @State private var errorMessage: String? @State private var pagination = PaginationState(pageSize: 100, currentPage: 0) @State private var hasMore = true + @State private var showInsertSheet = false + @State private var deleteTarget: Int? + @State private var showDeleteConfirmation = false + @State private var operationError: String? + @State private var showOperationError = false private let maxPreviewColumns = 4 + private var isView: Bool { + table.type == .view || table.type == .materializedView + } + + private var hasPrimaryKeys: Bool { + columnDetails.contains { $0.isPrimaryKey } + } + var body: some View { Group { if isLoading { @@ -52,12 +66,47 @@ struct DataBrowserView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .primaryAction) { - Text("\(rows.count) rows") - .font(.caption) - .foregroundStyle(.secondary) + HStack(spacing: 12) { + Text("\(rows.count) rows") + .font(.caption) + .foregroundStyle(.secondary) + + if !isView { + Button { + showInsertSheet = true + } label: { + Image(systemName: "plus") + } + } + } } } .task { await loadData() } + .sheet(isPresented: $showInsertSheet) { + InsertRowView( + table: table, + columnDetails: columnDetails, + session: session, + onInserted: { + Task { await loadData() } + } + ) + } + .alert("Delete Row", isPresented: $showDeleteConfirmation) { + Button("Delete", role: .destructive) { + if let index = deleteTarget { + Task { await deleteRow(at: index) } + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Are you sure you want to delete this row? This action cannot be undone.") + } + .alert("Error", isPresented: $showOperationError) { + Button("OK", role: .cancel) {} + } message: { + Text(operationError ?? "An unknown error occurred.") + } } private var cardList: some View { @@ -67,7 +116,13 @@ struct DataBrowserView: View { RowDetailView( columns: columns, rows: rows, - initialIndex: index + initialIndex: index, + table: table, + session: session, + columnDetails: columnDetails, + onSaved: { + Task { await loadData() } + } ) } label: { RowCard( @@ -77,6 +132,16 @@ struct DataBrowserView: View { ) } .listRowBackground(Color(.secondarySystemGroupedBackground)) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if !isView && hasPrimaryKeys { + Button(role: .destructive) { + deleteTarget = index + showDeleteConfirmation = true + } label: { + Label("Delete", systemImage: "trash") + } + } + } } if hasMore { @@ -117,11 +182,16 @@ struct DataBrowserView: View { pagination.reset() do { - let query = "SELECT * FROM \(table.name) LIMIT \(pagination.pageSize) OFFSET \(pagination.currentOffset)" + let query = "SELECT * FROM `\(table.name)` LIMIT \(pagination.pageSize) OFFSET \(pagination.currentOffset)" let result = try await session.driver.execute(query: query) self.columns = result.columns self.rows = result.rows self.hasMore = result.rows.count >= pagination.pageSize + + if columnDetails.isEmpty { + self.columnDetails = try await session.driver.fetchColumns(table: table.name, schema: nil) + } + isLoading = false } catch { errorMessage = error.localizedDescription @@ -136,7 +206,7 @@ struct DataBrowserView: View { pagination.currentPage += 1 do { - let query = "SELECT * FROM \(table.name) LIMIT \(pagination.pageSize) OFFSET \(pagination.currentOffset)" + let query = "SELECT * FROM `\(table.name)` LIMIT \(pagination.pageSize) OFFSET \(pagination.currentOffset)" let result = try await session.driver.execute(query: query) rows.append(contentsOf: result.rows) hasMore = result.rows.count >= pagination.pageSize @@ -146,6 +216,38 @@ struct DataBrowserView: View { isLoadingMore = false } + + private func deleteRow(at index: Int) async { + guard let session, index < rows.count else { return } + + let row = rows[index] + let pkValues = primaryKeyValues(for: row) + + guard !pkValues.isEmpty else { + operationError = "Cannot delete: no primary key columns found." + showOperationError = true + return + } + + let sql = SQLHelper.buildDelete(table: table.name, primaryKeys: pkValues) + + do { + _ = try await session.driver.execute(query: sql) + await loadData() + } catch { + operationError = error.localizedDescription + showOperationError = true + } + } + + private func primaryKeyValues(for row: [String?]) -> [(column: String, value: String)] { + columnDetails.enumerated().compactMap { index, col in + guard col.isPrimaryKey else { return nil } + let colIndex = columns.firstIndex(where: { $0.name == col.name }) + guard let colIndex, colIndex < row.count, let value = row[colIndex] else { return nil } + return (column: col.name, value: value) + } + } } private struct RowCard: View { diff --git a/TableProMobile/TableProMobile/Views/InsertRowView.swift b/TableProMobile/TableProMobile/Views/InsertRowView.swift new file mode 100644 index 000000000..ae6ed46a5 --- /dev/null +++ b/TableProMobile/TableProMobile/Views/InsertRowView.swift @@ -0,0 +1,202 @@ +// +// InsertRowView.swift +// TableProMobile +// + +import SwiftUI +import TableProDatabase +import TableProModels + +struct InsertRowView: View { + let table: TableInfo + let columnDetails: [ColumnInfo] + let session: ConnectionSession? + var onInserted: (() -> Void)? + + @Environment(\.dismiss) private var dismiss + @State private var values: [String] = [] + @State private var isNullFlags: [Bool] = [] + @State private var isSaving = false + @State private var operationError: String? + @State private var showOperationError = false + + var body: some View { + NavigationStack { + Form { + ForEach(Array(columnDetails.enumerated()), id: \.offset) { index, column in + Section { + HStack { + if isNullFlags[safe: index] == true { + Text("NULL") + .font(.body) + .foregroundStyle(.secondary) + .italic() + } else { + TextField(placeholder(for: column), text: binding(for: index)) + .font(.body) + .keyboardType(keyboardType(for: column)) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } + + Spacer() + + Button { + guard index < isNullFlags.count else { return } + isNullFlags[index].toggle() + if isNullFlags[index], index < values.count { + values[index] = "" + } + } label: { + Text("NULL") + .font(.caption2) + .foregroundStyle(isNullFlags[safe: index] == true ? .white : .secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(isNullFlags[safe: index] == true ? Color.accentColor : Color(.systemFill)) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } + } header: { + HStack(spacing: 6) { + if column.isPrimaryKey { + Image(systemName: "key.fill") + .font(.caption2) + .foregroundStyle(.orange) + } + Text(column.name) + + if column.isPrimaryKey { + Text("auto-increment") + .font(.caption2) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(column.typeName) + .font(.caption2) + .fontWeight(.medium) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.fill.tertiary) + .clipShape(Capsule()) + } + } footer: { + if let defaultValue = column.defaultValue { + Text("Default: \(defaultValue)") + .font(.caption2) + } + } + } + } + .formStyle(.grouped) + .navigationTitle("Insert Row") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + .disabled(isSaving) + } + ToolbarItem(placement: .confirmationAction) { + Button { + Task { await insertRow() } + } label: { + if isSaving { + ProgressView() + .controlSize(.small) + } else { + Text("Save") + } + } + .disabled(isSaving) + } + } + .onAppear { + values = Array(repeating: "", count: columnDetails.count) + isNullFlags = columnDetails.map { col in + col.isPrimaryKey || col.isNullable + } + } + .alert("Error", isPresented: $showOperationError) { + Button("OK", role: .cancel) {} + } message: { + Text(operationError ?? "An unknown error occurred.") + } + } + } + + private func binding(for index: Int) -> Binding { + Binding( + get: { values[safe: index] ?? "" }, + set: { newValue in + guard index < values.count else { return } + values[index] = newValue + } + ) + } + + private func placeholder(for column: ColumnInfo) -> String { + if column.isPrimaryKey { return "Auto" } + if let defaultValue = column.defaultValue { return "Default: \(defaultValue)" } + return column.typeName + } + + private func keyboardType(for column: ColumnInfo) -> UIKeyboardType { + let type = column.typeName.uppercased() + if type.contains("INT") || type.contains("REAL") || type.contains("FLOAT") + || type.contains("DOUBLE") || type.contains("NUMERIC") || type.contains("DECIMAL") + { + return .decimalPad + } + return .default + } + + private func insertRow() async { + guard let session else { return } + + isSaving = true + defer { isSaving = false } + + var insertColumns: [String] = [] + var insertValues: [String?] = [] + + for (index, column) in columnDetails.enumerated() { + let isNull = isNullFlags[safe: index] == true + let text = values[safe: index] ?? "" + + if column.isPrimaryKey && (isNull || text.isEmpty) { + continue + } + + insertColumns.append(column.name) + if isNull || text.isEmpty { + insertValues.append(nil) + } else { + insertValues.append(text) + } + } + + let sql = SQLHelper.buildInsert( + table: table.name, + columns: insertColumns, + values: insertValues + ) + + do { + _ = try await session.driver.execute(query: sql) + onInserted?() + dismiss() + } catch { + operationError = error.localizedDescription + showOperationError = true + } + } +} + +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/TableProMobile/TableProMobile/Views/RowDetailView.swift b/TableProMobile/TableProMobile/Views/RowDetailView.swift index 56b97678f..b5084c7ed 100644 --- a/TableProMobile/TableProMobile/Views/RowDetailView.swift +++ b/TableProMobile/TableProMobile/Views/RowDetailView.swift @@ -4,16 +4,40 @@ // import SwiftUI +import TableProDatabase import TableProModels struct RowDetailView: View { let columns: [ColumnInfo] let rows: [[String?]] + let table: TableInfo? + let session: ConnectionSession? + let columnDetails: [ColumnInfo] + var onSaved: (() -> Void)? + @State private var currentIndex: Int + @State private var isEditing = false + @State private var editedValues: [String?] = [] + @State private var isSaving = false + @State private var operationError: String? + @State private var showOperationError = false + @State private var showSaveSuccess = false - init(columns: [ColumnInfo], rows: [[String?]], initialIndex: Int) { + init( + columns: [ColumnInfo], + rows: [[String?]], + initialIndex: Int, + table: TableInfo? = nil, + session: ConnectionSession? = nil, + columnDetails: [ColumnInfo] = [], + onSaved: (() -> Void)? = nil + ) { self.columns = columns self.rows = rows + self.table = table + self.session = session + self.columnDetails = columnDetails + self.onSaved = onSaved _currentIndex = State(initialValue: initialIndex) } @@ -22,35 +46,68 @@ struct RowDetailView: View { return rows[currentIndex] } + private var isView: Bool { + guard let table else { return false } + return table.type == .view || table.type == .materializedView + } + + private var canEdit: Bool { + table != nil && session != nil && !columnDetails.isEmpty && !isView + && columnDetails.contains(where: { $0.isPrimaryKey }) + } + var body: some View { List { - ForEach(Array(zip(columns, currentRow).enumerated()), id: \.offset) { _, pair in + if showSaveSuccess { + Section { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + Text("Row updated successfully.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + + ForEach(Array(zip(columns, isEditing ? editedValues : currentRow).enumerated()), id: \.offset) { index, pair in let (column, value) = pair + let isPK = columnDetail(for: column.name)?.isPrimaryKey ?? column.isPrimaryKey Section { - fieldContent(value: value) - .contextMenu { - if let value { + if isEditing && !isPK { + editableField(index: index, value: value) + } else { + fieldContent(value: value) + .contextMenu { + if let value { + Button { + UIPasteboard.general.string = value + } label: { + Label("Copy Value", systemImage: "doc.on.doc") + } + } Button { - UIPasteboard.general.string = value + UIPasteboard.general.string = column.name } label: { - Label("Copy Value", systemImage: "doc.on.doc") + Label("Copy Column Name", systemImage: "textformat") } } - Button { - UIPasteboard.general.string = column.name - } label: { - Label("Copy Column Name", systemImage: "textformat") - } - } + } } header: { HStack(spacing: 6) { - if column.isPrimaryKey { + if isPK { Image(systemName: "key.fill") .font(.caption2) .foregroundStyle(.orange) } Text(column.name) + if isEditing && isPK { + Text("read-only") + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer() Text(column.typeName) @@ -68,13 +125,41 @@ struct RowDetailView: View { .navigationTitle("Row \(currentIndex + 1) of \(rows.count)") .navigationBarTitleDisplayMode(.inline) .toolbar { + ToolbarItem(placement: .primaryAction) { + if canEdit { + if isEditing { + Button { + Task { await saveChanges() } + } label: { + if isSaving { + ProgressView() + .controlSize(.small) + } else { + Text("Save") + } + } + .disabled(isSaving) + } else { + Button("Edit") { startEditing() } + } + } + } + + if isEditing { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { cancelEditing() } + .disabled(isSaving) + } + } + ToolbarItemGroup(placement: .bottomBar) { Button { withAnimation { currentIndex -= 1 } + if isEditing { startEditing() } } label: { Image(systemName: "chevron.left") } - .disabled(currentIndex <= 0) + .disabled(currentIndex <= 0 || isEditing) Spacer() @@ -87,10 +172,50 @@ struct RowDetailView: View { Button { withAnimation { currentIndex += 1 } + if isEditing { startEditing() } } label: { Image(systemName: "chevron.right") } - .disabled(currentIndex >= rows.count - 1) + .disabled(currentIndex >= rows.count - 1 || isEditing) + } + } + .alert("Error", isPresented: $showOperationError) { + Button("OK", role: .cancel) {} + } message: { + Text(operationError ?? "An unknown error occurred.") + } + } + + private func editableField(index: Int, value: String?) -> some View { + let binding = Binding( + get: { + guard index < editedValues.count else { return "" } + return editedValues[index] ?? "" + }, + set: { newValue in + guard index < editedValues.count else { return } + editedValues[index] = newValue.isEmpty ? nil : newValue + } + ) + + return HStack { + TextField("NULL", text: binding) + .font(.body) + + if value != nil { + Button { + guard index < editedValues.count else { return } + editedValues[index] = nil + } label: { + Text("NULL") + .font(.caption2) + .foregroundStyle(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.secondary) + .clipShape(Capsule()) + } + .buttonStyle(.plain) } } } @@ -108,4 +233,73 @@ struct RowDetailView: View { .italic() } } + + private func columnDetail(for name: String) -> ColumnInfo? { + columnDetails.first { $0.name == name } + } + + private func startEditing() { + editedValues = currentRow + isEditing = true + showSaveSuccess = false + } + + private func cancelEditing() { + isEditing = false + editedValues = [] + showSaveSuccess = false + } + + private func saveChanges() async { + guard let session, let table else { return } + + isSaving = true + defer { isSaving = false } + + let pkValues: [(column: String, value: String)] = columnDetails.compactMap { col in + guard col.isPrimaryKey else { return nil } + let colIndex = columns.firstIndex(where: { $0.name == col.name }) + guard let colIndex, colIndex < currentRow.count, let value = currentRow[colIndex] else { return nil } + return (column: col.name, value: value) + } + + guard !pkValues.isEmpty else { + operationError = "Cannot save: no primary key values found." + showOperationError = true + return + } + + var changes: [(column: String, value: String?)] = [] + for (index, column) in columns.enumerated() { + let isPK = columnDetail(for: column.name)?.isPrimaryKey ?? column.isPrimaryKey + if isPK { continue } + guard index < editedValues.count else { continue } + let oldValue = index < currentRow.count ? currentRow[index] : nil + let newValue = editedValues[index] + if oldValue != newValue { + changes.append((column: column.name, value: newValue)) + } + } + + guard !changes.isEmpty else { + isEditing = false + return + } + + let sql = SQLHelper.buildUpdate( + table: table.name, + changes: changes, + primaryKeys: pkValues + ) + + do { + _ = try await session.driver.execute(query: sql) + isEditing = false + showSaveSuccess = true + onSaved?() + } catch { + operationError = error.localizedDescription + showOperationError = true + } + } } From c8df3a04a76d04b38dcb5d0d42c2449057796d4b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 1 Apr 2026 00:54:05 +0700 Subject: [PATCH 21/61] fix: update local row data after successful edit in RowDetailView --- TableProMobile/TableProMobile/Views/RowDetailView.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/TableProMobile/TableProMobile/Views/RowDetailView.swift b/TableProMobile/TableProMobile/Views/RowDetailView.swift index b5084c7ed..d88fc76b1 100644 --- a/TableProMobile/TableProMobile/Views/RowDetailView.swift +++ b/TableProMobile/TableProMobile/Views/RowDetailView.swift @@ -9,7 +9,7 @@ import TableProModels struct RowDetailView: View { let columns: [ColumnInfo] - let rows: [[String?]] + @State private var rows: [[String?]] let table: TableInfo? let session: ConnectionSession? let columnDetails: [ColumnInfo] @@ -33,7 +33,7 @@ struct RowDetailView: View { onSaved: (() -> Void)? = nil ) { self.columns = columns - self.rows = rows + _rows = State(initialValue: rows) self.table = table self.session = session self.columnDetails = columnDetails @@ -294,6 +294,7 @@ struct RowDetailView: View { do { _ = try await session.driver.execute(query: sql) + rows[currentIndex] = editedValues isEditing = false showSaveSuccess = true onSaved?() From 32a93209dcbc2ed7f7337945f585f81105b29938 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 1 Apr 2026 00:54:58 +0700 Subject: [PATCH 22/61] fix: only default PK columns to NULL in InsertRowView, not all nullable columns --- TableProMobile/TableProMobile/Views/InsertRowView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TableProMobile/TableProMobile/Views/InsertRowView.swift b/TableProMobile/TableProMobile/Views/InsertRowView.swift index ae6ed46a5..5a2515f6f 100644 --- a/TableProMobile/TableProMobile/Views/InsertRowView.swift +++ b/TableProMobile/TableProMobile/Views/InsertRowView.swift @@ -116,7 +116,7 @@ struct InsertRowView: View { .onAppear { values = Array(repeating: "", count: columnDetails.count) isNullFlags = columnDetails.map { col in - col.isPrimaryKey || col.isNullable + col.isPrimaryKey } } .alert("Error", isPresented: $showOperationError) { From 3b9e50420e1f6c1dc4d8ca25baf8bc2375a0f7fe Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 1 Apr 2026 01:07:53 +0700 Subject: [PATCH 23/61] fix: reconnect on app active, fix 13 UI/UX issues across all iOS views --- .../TableProMobile/Views/ConnectedView.swift | 31 +++++++++++-- .../Views/ConnectionFormView.swift | 4 +- .../Views/ConnectionListView.swift | 7 +-- .../Views/DataBrowserView.swift | 24 ++++++---- .../TableProMobile/Views/InsertRowView.swift | 8 +++- .../Views/QueryEditorView.swift | 7 ++- .../TableProMobile/Views/RowDetailView.swift | 45 ++++++++++++------- 7 files changed, 88 insertions(+), 38 deletions(-) diff --git a/TableProMobile/TableProMobile/Views/ConnectedView.swift b/TableProMobile/TableProMobile/Views/ConnectedView.swift index c9259df31..5a6fbccb0 100644 --- a/TableProMobile/TableProMobile/Views/ConnectedView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectedView.swift @@ -9,6 +9,7 @@ import TableProModels struct ConnectedView: View { @Environment(AppState.self) private var appState + @Environment(\.scenePhase) private var scenePhase let connection: DatabaseConnection @State private var session: ConnectionSession? @@ -49,6 +50,11 @@ struct ConnectedView: View { .navigationTitle(displayName) .navigationBarTitleDisplayMode(.inline) .task { await connect() } + .onChange(of: scenePhase) { _, phase in + if phase == .active, session != nil { + Task { await reconnectIfNeeded() } + } + } } private var connectedContent: some View { @@ -62,19 +68,22 @@ struct ConnectedView: View { .padding(.horizontal) .padding(.vertical, 8) - switch selectedTab { - case .tables: + ZStack { TableListView( connection: connection, tables: tables, session: session, onRefresh: { await refreshTables() } ) - case .query: + .opacity(selectedTab == .tables ? 1 : 0) + .allowsHitTesting(selectedTab == .tables) + QueryEditorView( session: session, tables: tables ) + .opacity(selectedTab == .query ? 1 : 0) + .allowsHitTesting(selectedTab == .query) } } } @@ -94,6 +103,22 @@ struct ConnectedView: View { } } + private func reconnectIfNeeded() async { + guard let session else { return } + do { + _ = try await session.driver.ping() + } catch { + // Connection lost — reconnect + do { + let newSession = try await appState.connectionManager.connect(connection) + self.session = newSession + } catch { + errorMessage = error.localizedDescription + self.session = nil + } + } + } + private func refreshTables() async { guard let session else { return } do { diff --git a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift index 202dad414..eee22be44 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift @@ -297,8 +297,8 @@ struct ConnectionFormView: View { } do { - let session = try await appState.connectionManager.connect(connection) - try? await session.driver.disconnect() + _ = try await appState.connectionManager.connect(connection) + await appState.connectionManager.disconnect(connection.id) testResult = TestResult(success: true, message: "Connection successful") } catch { testResult = TestResult(success: false, message: error.localizedDescription) diff --git a/TableProMobile/TableProMobile/Views/ConnectionListView.swift b/TableProMobile/TableProMobile/Views/ConnectionListView.swift index be94c1ace..977d4ecf1 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionListView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionListView.swift @@ -26,6 +26,7 @@ struct ConnectionListView: View { } } .navigationTitle("Connections") + .navigationBarTitleDisplayMode(.large) .toolbar { ToolbarItem(placement: .primaryAction) { Button { @@ -142,11 +143,7 @@ private struct ConnectionRow: View { } Spacer() - - Circle() - .fill(.green.opacity(0.8)) - .frame(width: 8, height: 8) - } +} .padding(.vertical, 4) } diff --git a/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/TableProMobile/TableProMobile/Views/DataBrowserView.swift index e59781159..446ebefed 100644 --- a/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -53,11 +53,16 @@ struct DataBrowserView: View { .buttonStyle(.borderedProminent) } } else if rows.isEmpty { - ContentUnavailableView( - "No Data", - systemImage: "tray", - description: Text("This table is empty.") - ) + ContentUnavailableView { + Label("No Data", systemImage: "tray") + } description: { + Text("This table is empty.") + } actions: { + if !isView { + Button("Insert Row") { showInsertSheet = true } + .buttonStyle(.borderedProminent) + } + } } else { cardList } @@ -81,7 +86,7 @@ struct DataBrowserView: View { } } } - .task { await loadData() } + .task { await loadData(isInitial: true) } .sheet(isPresented: $showInsertSheet) { InsertRowView( table: table, @@ -131,7 +136,6 @@ struct DataBrowserView: View { maxPreviewColumns: maxPreviewColumns ) } - .listRowBackground(Color(.secondarySystemGroupedBackground)) .swipeActions(edge: .trailing, allowsFullSwipe: false) { if !isView && hasPrimaryKeys { Button(role: .destructive) { @@ -170,14 +174,16 @@ struct DataBrowserView: View { .refreshable { await loadData() } } - private func loadData() async { + private func loadData(isInitial: Bool = false) async { guard let session else { errorMessage = "Not connected" isLoading = false return } - isLoading = true + if isInitial || rows.isEmpty { + isLoading = true + } errorMessage = nil pagination.reset() diff --git a/TableProMobile/TableProMobile/Views/InsertRowView.swift b/TableProMobile/TableProMobile/Views/InsertRowView.swift index 5a2515f6f..ce9808e3d 100644 --- a/TableProMobile/TableProMobile/Views/InsertRowView.swift +++ b/TableProMobile/TableProMobile/Views/InsertRowView.swift @@ -68,7 +68,7 @@ struct InsertRowView: View { Text(column.name) if column.isPrimaryKey { - Text("auto-increment") + Text(isAutoIncrement(column) ? "auto-increment" : "primary key") .font(.caption2) .foregroundStyle(.secondary) } @@ -143,6 +143,10 @@ struct InsertRowView: View { return column.typeName } + private func isAutoIncrement(_ column: ColumnInfo) -> Bool { + column.isPrimaryKey && column.typeName.uppercased().contains("INT") + } + private func keyboardType(for column: ColumnInfo) -> UIKeyboardType { let type = column.typeName.uppercased() if type.contains("INT") || type.contains("REAL") || type.contains("FLOAT") @@ -171,7 +175,7 @@ struct InsertRowView: View { } insertColumns.append(column.name) - if isNull || text.isEmpty { + if isNull { insertValues.append(nil) } else { insertValues.append(text) diff --git a/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/TableProMobile/TableProMobile/Views/QueryEditorView.swift index 13120490f..2ff0978d8 100644 --- a/TableProMobile/TableProMobile/Views/QueryEditorView.swift +++ b/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -25,7 +25,10 @@ struct QueryEditorView: View { var body: some View { VStack(spacing: 0) { editorArea - keywordAccessory + if editorFocused { + keywordAccessory + .transition(.move(edge: .bottom).combined(with: .opacity)) + } Divider() resultArea } @@ -98,7 +101,7 @@ struct QueryEditorView: View { .textInputAutocapitalization(.never) .keyboardType(.asciiCapable) .scrollContentBackground(.hidden) - .frame(minHeight: 100, maxHeight: 180) + .frame(minHeight: 100, maxHeight: result != nil || errorMessage != nil ? 150 : 300) .padding(.horizontal, 8) .padding(.vertical, 4) .focused($editorFocused) diff --git a/TableProMobile/TableProMobile/Views/RowDetailView.swift b/TableProMobile/TableProMobile/Views/RowDetailView.swift index d88fc76b1..308f60114 100644 --- a/TableProMobile/TableProMobile/Views/RowDetailView.swift +++ b/TableProMobile/TableProMobile/Views/RowDetailView.swift @@ -194,29 +194,40 @@ struct RowDetailView: View { }, set: { newValue in guard index < editedValues.count else { return } - editedValues[index] = newValue.isEmpty ? nil : newValue + editedValues[index] = newValue } ) + let isNull = index < editedValues.count ? editedValues[index] == nil : true + return HStack { - TextField("NULL", text: binding) - .font(.body) + if isNull { + Text("NULL") + .font(.body) + .foregroundStyle(.secondary) + .italic() + } else { + TextField("Value", text: binding) + .font(.body) + } - if value != nil { - Button { - guard index < editedValues.count else { return } + Button { + guard index < editedValues.count else { return } + if editedValues[index] == nil { + editedValues[index] = "" + } else { editedValues[index] = nil - } label: { - Text("NULL") - .font(.caption2) - .foregroundStyle(.white) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(.secondary) - .clipShape(Capsule()) } - .buttonStyle(.plain) + } label: { + Text("NULL") + .font(.caption2) + .foregroundStyle(isNull ? .white : .secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(isNull ? Color.accentColor : Color(.systemFill)) + .clipShape(Capsule()) } + .buttonStyle(.plain) } } @@ -298,6 +309,10 @@ struct RowDetailView: View { isEditing = false showSaveSuccess = true onSaved?() + Task { + try? await Task.sleep(nanoseconds: 2_000_000_000) + withAnimation { showSaveSuccess = false } + } } catch { operationError = error.localizedDescription showOperationError = true From 722c28fdbafce8825bb94f6949e46ed385ebf898 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 1 Apr 2026 21:43:14 +0700 Subject: [PATCH 24/61] feat: add iOS cross-compile scripts for OpenSSL, hiredis, libpq, MariaDB --- scripts/ios/build-hiredis-ios.sh | 185 +++++++++++++++++++++++++++++ scripts/ios/build-libpq-ios.sh | 196 +++++++++++++++++++++++++++++++ scripts/ios/build-mariadb-ios.sh | 173 +++++++++++++++++++++++++++ scripts/ios/build-openssl-ios.sh | 183 +++++++++++++++++++++++++++++ 4 files changed, 737 insertions(+) create mode 100755 scripts/ios/build-hiredis-ios.sh create mode 100755 scripts/ios/build-libpq-ios.sh create mode 100755 scripts/ios/build-mariadb-ios.sh create mode 100755 scripts/ios/build-openssl-ios.sh diff --git a/scripts/ios/build-hiredis-ios.sh b/scripts/ios/build-hiredis-ios.sh new file mode 100755 index 000000000..df3889560 --- /dev/null +++ b/scripts/ios/build-hiredis-ios.sh @@ -0,0 +1,185 @@ +#!/bin/bash +set -eo pipefail + +# Build static hiredis (with SSL) for iOS → xcframework +# +# Requires: OpenSSL xcframework already built (run build-openssl-ios.sh first) +# +# Produces: Libs/ios/Hiredis.xcframework/ +# +# Usage: +# ./scripts/ios/build-hiredis-ios.sh + +HIREDIS_VERSION="1.2.0" +HIREDIS_SHA256="82ad632d31ee05da13b537c124f819eb88e18851d9cb0c30ae0552084811588c" +IOS_DEPLOY_TARGET="17.0" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +LIBS_DIR="$PROJECT_DIR/Libs/ios" +BUILD_DIR="$(mktemp -d)" +NCPU=$(sysctl -n hw.ncpu) + +run_quiet() { + local logfile + logfile=$(mktemp) + if ! "$@" > "$logfile" 2>&1; then + echo "FAILED: $*" + tail -50 "$logfile" + rm -f "$logfile" + return 1 + fi + rm -f "$logfile" +} + +cleanup() { + echo " Cleaning up build directory..." + rm -rf "$BUILD_DIR" +} +trap cleanup EXIT + +echo "Building static hiredis $HIREDIS_VERSION for iOS" +echo " Build dir: $BUILD_DIR" + +# --- Locate OpenSSL from xcframework --- + +resolve_openssl() { + local PLATFORM=$1 # ios-arm64 or ios-arm64-simulator + local XCFW_SSL="$LIBS_DIR/OpenSSL-SSL.xcframework" + local XCFW_CRYPTO="$LIBS_DIR/OpenSSL-Crypto.xcframework" + + if [ ! -d "$XCFW_SSL" ] || [ ! -d "$XCFW_CRYPTO" ]; then + echo "ERROR: OpenSSL xcframeworks not found. Run build-openssl-ios.sh first." + exit 1 + fi + + # Find the correct slice directory + local SSL_LIB=$(find "$XCFW_SSL" -path "*$PLATFORM*/libssl.a" | head -1) + local CRYPTO_LIB=$(find "$XCFW_CRYPTO" -path "*$PLATFORM*/libcrypto.a" | head -1) + local HEADERS=$(find "$XCFW_SSL" -path "*$PLATFORM*/Headers" -type d | head -1) + + if [ -z "$SSL_LIB" ] || [ -z "$CRYPTO_LIB" ]; then + echo "ERROR: Could not find OpenSSL libs for platform $PLATFORM" + exit 1 + fi + + OPENSSL_SSL_LIB="$SSL_LIB" + OPENSSL_CRYPTO_LIB="$CRYPTO_LIB" + OPENSSL_INCLUDE="$HEADERS" + OPENSSL_LIB_DIR="$(dirname "$SSL_LIB")" +} + +# --- Download hiredis --- + +echo "=> Downloading hiredis $HIREDIS_VERSION..." +curl -fSL "https://github.com/redis/hiredis/archive/refs/tags/v$HIREDIS_VERSION.tar.gz" \ + -o "$BUILD_DIR/hiredis.tar.gz" +echo "$HIREDIS_SHA256 $BUILD_DIR/hiredis.tar.gz" | shasum -a 256 -c - > /dev/null + +tar xzf "$BUILD_DIR/hiredis.tar.gz" -C "$BUILD_DIR" +HIREDIS_SRC="$BUILD_DIR/hiredis-$HIREDIS_VERSION" + +# --- Build function --- + +build_hiredis_slice() { + local SDK_NAME=$1 # iphoneos or iphonesimulator + local ARCH=$2 # arm64 + local PLATFORM_KEY=$3 # ios-arm64 or ios-arm64-simulator + local INSTALL_DIR="$BUILD_DIR/install-$SDK_NAME-$ARCH" + + echo "=> Building hiredis for $SDK_NAME ($ARCH)..." + + resolve_openssl "$PLATFORM_KEY" + + local SDK_PATH + SDK_PATH=$(xcrun --sdk "$SDK_NAME" --show-sdk-path) + + local SRC_COPY="$BUILD_DIR/hiredis-$SDK_NAME-$ARCH" + cp -R "$HIREDIS_SRC" "$SRC_COPY" + + local BUILD="$SRC_COPY/cmake-build" + mkdir -p "$BUILD" + cd "$BUILD" + + local CMAKE_SYSTEM + if [ "$SDK_NAME" = "iphoneos" ]; then + CMAKE_SYSTEM="iOS" + else + CMAKE_SYSTEM="iOS" + fi + + # Create a temporary OpenSSL prefix that cmake can find + local OPENSSL_PREFIX="$BUILD_DIR/openssl-prefix-$SDK_NAME-$ARCH" + mkdir -p "$OPENSSL_PREFIX/lib" "$OPENSSL_PREFIX/include" + cp "$OPENSSL_SSL_LIB" "$OPENSSL_PREFIX/lib/" + cp "$OPENSSL_CRYPTO_LIB" "$OPENSSL_PREFIX/lib/" + if [ -d "$OPENSSL_INCLUDE" ]; then + cp -R "$OPENSSL_INCLUDE/openssl" "$OPENSSL_PREFIX/include/" 2>/dev/null || true + fi + + run_quiet cmake .. \ + -DCMAKE_SYSTEM_NAME=iOS \ + -DCMAKE_OSX_DEPLOYMENT_TARGET="$IOS_DEPLOY_TARGET" \ + -DCMAKE_OSX_ARCHITECTURES="$ARCH" \ + -DCMAKE_OSX_SYSROOT="$SDK_PATH" \ + -DCMAKE_INSTALL_PREFIX="$INSTALL_DIR" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \ + -DBUILD_SHARED_LIBS=OFF \ + -DENABLE_SSL=ON \ + -DDISABLE_TESTS=ON \ + -DENABLE_EXAMPLES=OFF \ + -DOPENSSL_ROOT_DIR="$OPENSSL_PREFIX" \ + -DOPENSSL_SSL_LIBRARY="$OPENSSL_PREFIX/lib/libssl.a" \ + -DOPENSSL_CRYPTO_LIBRARY="$OPENSSL_PREFIX/lib/libcrypto.a" \ + -DOPENSSL_INCLUDE_DIR="$OPENSSL_PREFIX/include" + + run_quiet cmake --build . --config Release -j"$NCPU" + run_quiet cmake --install . --config Release + + echo " Installed to $INSTALL_DIR" +} + +# --- Build slices --- + +build_hiredis_slice "iphoneos" "arm64" "ios-arm64" +build_hiredis_slice "iphonesimulator" "arm64" "ios-arm64-simulator" + +# --- Create xcframeworks --- + +DEVICE_DIR="$BUILD_DIR/install-iphoneos-arm64" +SIM_DIR="$BUILD_DIR/install-iphonesimulator-arm64" + +rm -rf "$LIBS_DIR/Hiredis.xcframework" +rm -rf "$LIBS_DIR/Hiredis-SSL.xcframework" + +echo "=> Creating Hiredis.xcframework..." + +xcodebuild -create-xcframework \ + -library "$DEVICE_DIR/lib/libhiredis.a" \ + -headers "$DEVICE_DIR/include" \ + -library "$SIM_DIR/lib/libhiredis.a" \ + -headers "$SIM_DIR/include" \ + -output "$LIBS_DIR/Hiredis.xcframework" + +echo "=> Creating Hiredis-SSL.xcframework..." + +xcodebuild -create-xcframework \ + -library "$DEVICE_DIR/lib/libhiredis_ssl.a" \ + -library "$SIM_DIR/lib/libhiredis_ssl.a" \ + -output "$LIBS_DIR/Hiredis-SSL.xcframework" + +echo "" +echo "hiredis $HIREDIS_VERSION for iOS built successfully!" +echo " $LIBS_DIR/Hiredis.xcframework" +echo " $LIBS_DIR/Hiredis-SSL.xcframework" + +# --- Verify --- + +echo "" +echo "=> Verifying device slice..." +lipo -info "$DEVICE_DIR/lib/libhiredis.a" +otool -l "$DEVICE_DIR/lib/libhiredis.a" | grep -A4 "LC_BUILD_VERSION" | head -5 + +echo "" +echo "Done!" diff --git a/scripts/ios/build-libpq-ios.sh b/scripts/ios/build-libpq-ios.sh new file mode 100755 index 000000000..3a059075f --- /dev/null +++ b/scripts/ios/build-libpq-ios.sh @@ -0,0 +1,196 @@ +#!/bin/bash +set -eo pipefail + +# Build static libpq (PostgreSQL) for iOS → xcframework +# +# Requires: OpenSSL xcframework already built +# +# Produces: Libs/ios/LibPQ.xcframework/ +# +# Usage: +# ./scripts/ios/build-libpq-ios.sh + +PG_VERSION="17.4" +PG_SHA256="1b9e50ed65ef9e4e4ed3c073cb9950a8e38e94a2e7e3c5e4b5b56e585e104248" +IOS_DEPLOY_TARGET="17.0" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +LIBS_DIR="$PROJECT_DIR/Libs/ios" +BUILD_DIR="$(mktemp -d)" +NCPU=$(sysctl -n hw.ncpu) + +run_quiet() { + local logfile + logfile=$(mktemp) + if ! "$@" > "$logfile" 2>&1; then + echo "FAILED: $*" + tail -50 "$logfile" + rm -f "$logfile" + return 1 + fi + rm -f "$logfile" +} + +cleanup() { + echo " Cleaning up build directory..." + rm -rf "$BUILD_DIR" +} +trap cleanup EXIT + +echo "Building static libpq (PostgreSQL $PG_VERSION) for iOS" +echo " Build dir: $BUILD_DIR" + +# --- Locate OpenSSL --- + +setup_openssl_prefix() { + local PLATFORM_KEY=$1 # ios-arm64 or ios-arm64-simulator + local PREFIX_DIR="$BUILD_DIR/openssl-$PLATFORM_KEY" + + local SSL_LIB=$(find "$LIBS_DIR/OpenSSL-SSL.xcframework" -path "*$PLATFORM_KEY*/libssl.a" | head -1) + local CRYPTO_LIB=$(find "$LIBS_DIR/OpenSSL-Crypto.xcframework" -path "*$PLATFORM_KEY*/libcrypto.a" | head -1) + local HEADERS=$(find "$LIBS_DIR/OpenSSL-SSL.xcframework" -path "*$PLATFORM_KEY*/Headers" -type d | head -1) + + if [ -z "$SSL_LIB" ] || [ -z "$CRYPTO_LIB" ]; then + echo "ERROR: OpenSSL not found for $PLATFORM_KEY. Run build-openssl-ios.sh first." + exit 1 + fi + + mkdir -p "$PREFIX_DIR/lib" "$PREFIX_DIR/include" + cp "$SSL_LIB" "$PREFIX_DIR/lib/" + cp "$CRYPTO_LIB" "$PREFIX_DIR/lib/" + [ -d "$HEADERS" ] && cp -R "$HEADERS/openssl" "$PREFIX_DIR/include/" 2>/dev/null || true + + OPENSSL_PREFIX="$PREFIX_DIR" +} + +# --- Download PostgreSQL --- + +echo "=> Downloading PostgreSQL $PG_VERSION..." +curl -fSL "https://ftp.postgresql.org/pub/source/v$PG_VERSION/postgresql-$PG_VERSION.tar.bz2" \ + -o "$BUILD_DIR/postgresql.tar.bz2" +echo "$PG_SHA256 $BUILD_DIR/postgresql.tar.bz2" | shasum -a 256 -c - > /dev/null + +tar xjf "$BUILD_DIR/postgresql.tar.bz2" -C "$BUILD_DIR" +PG_SRC="$BUILD_DIR/postgresql-$PG_VERSION" + +# --- Build function --- + +build_libpq_slice() { + local SDK_NAME=$1 # iphoneos or iphonesimulator + local ARCH=$2 # arm64 + local PLATFORM_KEY=$3 # ios-arm64 or ios-arm64-simulator + local INSTALL_DIR="$BUILD_DIR/install-$SDK_NAME-$ARCH" + + echo "=> Building libpq for $SDK_NAME ($ARCH)..." + + setup_openssl_prefix "$PLATFORM_KEY" + + local SDK_PATH + SDK_PATH=$(xcrun --sdk "$SDK_NAME" --show-sdk-path) + + local SRC_COPY="$BUILD_DIR/pg-$SDK_NAME-$ARCH" + cp -R "$PG_SRC" "$SRC_COPY" + cd "$SRC_COPY" + + local HOST="aarch64-apple-darwin" + local TARGET_FLAG="" + if [ "$SDK_NAME" = "iphonesimulator" ]; then + TARGET_FLAG="-target arm64-apple-ios${IOS_DEPLOY_TARGET}-simulator" + else + TARGET_FLAG="-target arm64-apple-ios${IOS_DEPLOY_TARGET}" + fi + + export IPHONEOS_DEPLOYMENT_TARGET="$IOS_DEPLOY_TARGET" + + run_quiet env \ + CFLAGS="-arch $ARCH -isysroot $SDK_PATH $TARGET_FLAG -mios-version-min=$IOS_DEPLOY_TARGET -Wno-unguarded-availability-new -I$OPENSSL_PREFIX/include" \ + LDFLAGS="-arch $ARCH -isysroot $SDK_PATH $TARGET_FLAG -L$OPENSSL_PREFIX/lib" \ + ac_cv_func_strchrnul=yes \ + ./configure \ + --prefix="$INSTALL_DIR" \ + --host="$HOST" \ + --with-ssl=openssl \ + --without-readline \ + --without-icu \ + --without-gssapi + + # strchrnul compat + cat > src/port/strchrnul_compat.c << 'COMPAT_EOF' +#include +char *strchrnul(const char *s, int c) { + while (*s && *s != (char)c) s++; + return (char *)s; +} +COMPAT_EOF + + run_quiet make -C src/include -j"$NCPU" + run_quiet make -C src/common -j"$NCPU" + run_quiet make -C src/port -j"$NCPU" + run_quiet make -C src/interfaces/libpq all-static-lib -j"$NCPU" + + # Add strchrnul compat + xcrun --sdk "$SDK_NAME" cc -arch "$ARCH" -isysroot "$SDK_PATH" $TARGET_FLAG \ + -c -o src/port/strchrnul_compat.o src/port/strchrnul_compat.c + run_quiet ar rs src/port/libpgport_shlib.a src/port/strchrnul_compat.o + + mkdir -p "$INSTALL_DIR/lib" "$INSTALL_DIR/include" + cp src/interfaces/libpq/libpq.a "$INSTALL_DIR/lib/" + cp src/common/libpgcommon_shlib.a "$INSTALL_DIR/lib/libpgcommon.a" + cp src/port/libpgport_shlib.a "$INSTALL_DIR/lib/libpgport.a" + + # Copy headers + cp src/interfaces/libpq/libpq-fe.h "$INSTALL_DIR/include/" + cp src/include/libpq/libpq-fs.h "$INSTALL_DIR/include/" 2>/dev/null || true + cp src/include/postgres_ext.h "$INSTALL_DIR/include/" + cp src/include/pg_config_ext.h "$INSTALL_DIR/include/" 2>/dev/null || true + + echo " Installed to $INSTALL_DIR" +} + +# --- Build slices --- + +build_libpq_slice "iphoneos" "arm64" "ios-arm64" +build_libpq_slice "iphonesimulator" "arm64" "ios-arm64-simulator" + +# --- Create xcframeworks --- + +DEVICE_DIR="$BUILD_DIR/install-iphoneos-arm64" +SIM_DIR="$BUILD_DIR/install-iphonesimulator-arm64" + +rm -rf "$LIBS_DIR/LibPQ.xcframework" + +echo "=> Creating LibPQ.xcframework..." + +# Merge libpq + libpgcommon + libpgport into single archive per slice +for DIR in "$DEVICE_DIR" "$SIM_DIR"; do + mkdir -p "$DIR/merged" + cp "$DIR/lib/libpq.a" "$DIR/merged/" + # Extract and re-archive pgcommon + pgport into libpq + cd "$DIR/merged" + ar x "$DIR/lib/libpgcommon.a" + ar x "$DIR/lib/libpgport.a" + ar rs libpq.a *.o 2>/dev/null + rm -f *.o +done + +xcodebuild -create-xcframework \ + -library "$DEVICE_DIR/merged/libpq.a" \ + -headers "$DEVICE_DIR/include" \ + -library "$SIM_DIR/merged/libpq.a" \ + -headers "$SIM_DIR/include" \ + -output "$LIBS_DIR/LibPQ.xcframework" + +echo "" +echo "libpq (PostgreSQL $PG_VERSION) for iOS built successfully!" +echo " $LIBS_DIR/LibPQ.xcframework" + +# --- Verify --- + +echo "" +echo "=> Verifying device slice..." +lipo -info "$DEVICE_DIR/lib/libpq.a" +otool -l "$DEVICE_DIR/lib/libpq.a" | grep -A4 "LC_BUILD_VERSION" | head -5 + +echo "" +echo "Done!" diff --git a/scripts/ios/build-mariadb-ios.sh b/scripts/ios/build-mariadb-ios.sh new file mode 100755 index 000000000..98814c01c --- /dev/null +++ b/scripts/ios/build-mariadb-ios.sh @@ -0,0 +1,173 @@ +#!/bin/bash +set -eo pipefail + +# Build static MariaDB Connector/C for iOS → xcframework +# +# Requires: OpenSSL xcframework already built +# +# Produces: Libs/ios/MariaDB.xcframework/ +# +# Usage: +# ./scripts/ios/build-mariadb-ios.sh + +MARIADB_VERSION="3.4.4" +IOS_DEPLOY_TARGET="17.0" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +LIBS_DIR="$PROJECT_DIR/Libs/ios" +BUILD_DIR="$(mktemp -d)" +NCPU=$(sysctl -n hw.ncpu) + +run_quiet() { + local logfile + logfile=$(mktemp) + if ! "$@" > "$logfile" 2>&1; then + echo "FAILED: $*" + tail -50 "$logfile" + rm -f "$logfile" + return 1 + fi + rm -f "$logfile" +} + +cleanup() { + echo " Cleaning up build directory..." + rm -rf "$BUILD_DIR" +} +trap cleanup EXIT + +echo "Building static MariaDB Connector/C $MARIADB_VERSION for iOS" +echo " Build dir: $BUILD_DIR" + +# --- Locate OpenSSL --- + +setup_openssl_prefix() { + local PLATFORM_KEY=$1 + local PREFIX_DIR="$BUILD_DIR/openssl-$PLATFORM_KEY" + + local SSL_LIB=$(find "$LIBS_DIR/OpenSSL-SSL.xcframework" -path "*$PLATFORM_KEY*/libssl.a" | head -1) + local CRYPTO_LIB=$(find "$LIBS_DIR/OpenSSL-Crypto.xcframework" -path "*$PLATFORM_KEY*/libcrypto.a" | head -1) + local HEADERS=$(find "$LIBS_DIR/OpenSSL-SSL.xcframework" -path "*$PLATFORM_KEY*/Headers" -type d | head -1) + + if [ -z "$SSL_LIB" ] || [ -z "$CRYPTO_LIB" ]; then + echo "ERROR: OpenSSL not found for $PLATFORM_KEY. Run build-openssl-ios.sh first." + exit 1 + fi + + mkdir -p "$PREFIX_DIR/lib" "$PREFIX_DIR/include" + cp "$SSL_LIB" "$PREFIX_DIR/lib/" + cp "$CRYPTO_LIB" "$PREFIX_DIR/lib/" + [ -d "$HEADERS" ] && cp -R "$HEADERS/openssl" "$PREFIX_DIR/include/" 2>/dev/null || true + + OPENSSL_PREFIX="$PREFIX_DIR" +} + +# --- Download MariaDB Connector/C --- + +echo "=> Downloading MariaDB Connector/C $MARIADB_VERSION..." +curl -fSL "https://github.com/mariadb-corporation/mariadb-connector-c/archive/refs/tags/v$MARIADB_VERSION.tar.gz" \ + -o "$BUILD_DIR/mariadb.tar.gz" + +tar xzf "$BUILD_DIR/mariadb.tar.gz" -C "$BUILD_DIR" +MARIADB_SRC="$BUILD_DIR/mariadb-connector-c-$MARIADB_VERSION" + +# --- Build function --- + +build_mariadb_slice() { + local SDK_NAME=$1 # iphoneos or iphonesimulator + local ARCH=$2 # arm64 + local PLATFORM_KEY=$3 # ios-arm64 or ios-arm64-simulator + local INSTALL_DIR="$BUILD_DIR/install-$SDK_NAME-$ARCH" + + echo "=> Building MariaDB Connector/C for $SDK_NAME ($ARCH)..." + + setup_openssl_prefix "$PLATFORM_KEY" + + local SDK_PATH + SDK_PATH=$(xcrun --sdk "$SDK_NAME" --show-sdk-path) + + local SRC_COPY="$BUILD_DIR/mariadb-$SDK_NAME-$ARCH" + cp -R "$MARIADB_SRC" "$SRC_COPY" + + local BUILD="$SRC_COPY/cmake-build" + mkdir -p "$BUILD" + cd "$BUILD" + + run_quiet cmake .. \ + -DCMAKE_SYSTEM_NAME=iOS \ + -DCMAKE_OSX_DEPLOYMENT_TARGET="$IOS_DEPLOY_TARGET" \ + -DCMAKE_OSX_ARCHITECTURES="$ARCH" \ + -DCMAKE_OSX_SYSROOT="$SDK_PATH" \ + -DCMAKE_INSTALL_PREFIX="$INSTALL_DIR" \ + -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_SHARED_LIBS=OFF \ + -DWITH_SSL=OPENSSL \ + -DOPENSSL_ROOT_DIR="$OPENSSL_PREFIX" \ + -DOPENSSL_SSL_LIBRARY="$OPENSSL_PREFIX/lib/libssl.a" \ + -DOPENSSL_CRYPTO_LIBRARY="$OPENSSL_PREFIX/lib/libcrypto.a" \ + -DOPENSSL_INCLUDE_DIR="$OPENSSL_PREFIX/include" \ + -DWITH_UNIT_TESTS=OFF \ + -DWITH_CURL=OFF \ + -DCLIENT_PLUGIN_AUTH_GSSAPI_CLIENT=OFF \ + -DCLIENT_PLUGIN_DIALOG=STATIC \ + -DCLIENT_PLUGIN_MYSQL_CLEAR_PASSWORD=STATIC \ + -DCLIENT_PLUGIN_CACHING_SHA2_PASSWORD=STATIC \ + -DCLIENT_PLUGIN_SHA256_PASSWORD=STATIC \ + -DCLIENT_PLUGIN_MYSQL_NATIVE_PASSWORD=STATIC \ + -DCLIENT_PLUGIN_MYSQL_OLD_PASSWORD=OFF \ + -DCLIENT_PLUGIN_PVIO_NPIPE=OFF \ + -DCLIENT_PLUGIN_PVIO_SHMEM=OFF + + run_quiet cmake --build . --config Release -j"$NCPU" + run_quiet cmake --install . --config Release + + echo " Installed to $INSTALL_DIR" +} + +# --- Build slices --- + +build_mariadb_slice "iphoneos" "arm64" "ios-arm64" +build_mariadb_slice "iphonesimulator" "arm64" "ios-arm64-simulator" + +# --- Create xcframework --- + +DEVICE_DIR="$BUILD_DIR/install-iphoneos-arm64" +SIM_DIR="$BUILD_DIR/install-iphonesimulator-arm64" + +rm -rf "$LIBS_DIR/MariaDB.xcframework" + +# Find the actual .a file (may be in lib/ or lib/mariadb/) +DEVICE_LIB=$(find "$DEVICE_DIR" -name "libmariadb.a" | head -1) +SIM_LIB=$(find "$SIM_DIR" -name "libmariadb.a" | head -1) +DEVICE_HEADERS=$(find "$DEVICE_DIR" -name "mysql.h" -exec dirname {} \; | head -1) + +if [ -z "$DEVICE_LIB" ] || [ -z "$SIM_LIB" ]; then + echo "ERROR: libmariadb.a not found in install directories" + echo "Device contents:"; find "$DEVICE_DIR" -name "*.a" + echo "Sim contents:"; find "$SIM_DIR" -name "*.a" + exit 1 +fi + +echo "=> Creating MariaDB.xcframework..." + +xcodebuild -create-xcframework \ + -library "$DEVICE_LIB" \ + -headers "$DEVICE_HEADERS" \ + -library "$SIM_LIB" \ + -headers "$(find "$SIM_DIR" -name "mysql.h" -exec dirname {} \; | head -1)" \ + -output "$LIBS_DIR/MariaDB.xcframework" + +echo "" +echo "MariaDB Connector/C $MARIADB_VERSION for iOS built successfully!" +echo " $LIBS_DIR/MariaDB.xcframework" + +# --- Verify --- + +echo "" +echo "=> Verifying device slice..." +lipo -info "$DEVICE_LIB" +otool -l "$DEVICE_LIB" | grep -A4 "LC_BUILD_VERSION" | head -5 + +echo "" +echo "Done!" diff --git a/scripts/ios/build-openssl-ios.sh b/scripts/ios/build-openssl-ios.sh new file mode 100755 index 000000000..94db4b650 --- /dev/null +++ b/scripts/ios/build-openssl-ios.sh @@ -0,0 +1,183 @@ +#!/bin/bash +set -eo pipefail + +# Build static OpenSSL for iOS (device + simulator) → xcframework +# +# Produces: Libs/ios/OpenSSL.xcframework/ +# - ios-arm64/ (device) +# - ios-arm64-simulator/ (simulator on Apple Silicon) +# +# Usage: +# ./scripts/ios/build-openssl-ios.sh +# +# Prerequisites: +# - Xcode Command Line Tools +# - curl + +OPENSSL_VERSION="3.4.1" +OPENSSL_SHA256="002a2d6b30b58bf4bea46c43bdd96365aaf8daa6c428782aa4feee06da197df3" +IOS_DEPLOY_TARGET="17.0" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +LIBS_DIR="$PROJECT_DIR/Libs/ios" +BUILD_DIR="$(mktemp -d)" +NCPU=$(sysctl -n hw.ncpu) + +run_quiet() { + local logfile + logfile=$(mktemp) + if ! "$@" > "$logfile" 2>&1; then + echo "FAILED: $*" + tail -50 "$logfile" + rm -f "$logfile" + return 1 + fi + rm -f "$logfile" +} + +cleanup() { + echo " Cleaning up build directory..." + rm -rf "$BUILD_DIR" +} +trap cleanup EXIT + +echo "Building static OpenSSL $OPENSSL_VERSION for iOS" +echo " iOS deployment target: $IOS_DEPLOY_TARGET" +echo " Build dir: $BUILD_DIR" + +mkdir -p "$LIBS_DIR" + +# --- Download OpenSSL --- + +OPENSSL_TARBALL="$BUILD_DIR/openssl-$OPENSSL_VERSION.tar.gz" +OPENSSL_SRC="$BUILD_DIR/openssl-$OPENSSL_VERSION" + +echo "=> Downloading OpenSSL $OPENSSL_VERSION..." +curl -sL "https://github.com/openssl/openssl/releases/download/openssl-$OPENSSL_VERSION/openssl-$OPENSSL_VERSION.tar.gz" -o "$OPENSSL_TARBALL" + +echo " Verifying checksum..." +echo "$OPENSSL_SHA256 $OPENSSL_TARBALL" | shasum -a 256 -c - > /dev/null + +tar xzf "$OPENSSL_TARBALL" -C "$BUILD_DIR" + +# --- Build function --- + +build_openssl_slice() { + local PLATFORM=$1 # iphoneos or iphonesimulator + local ARCH=$2 # arm64 + local TARGET=$3 # OpenSSL configure target + local INSTALL_DIR="$BUILD_DIR/install-$PLATFORM-$ARCH" + + echo "=> Building OpenSSL for $PLATFORM ($ARCH)..." + + local SRC_COPY="$BUILD_DIR/openssl-$PLATFORM-$ARCH" + cp -R "$OPENSSL_SRC" "$SRC_COPY" + cd "$SRC_COPY" + + local SDK_PATH + SDK_PATH=$(xcrun --sdk "$PLATFORM" --show-sdk-path) + + export IPHONEOS_DEPLOYMENT_TARGET="$IOS_DEPLOY_TARGET" + + run_quiet ./Configure "$TARGET" \ + no-shared no-tests no-apps no-docs no-engine no-async \ + no-comp no-dtls no-psk no-srp no-ssl3 no-dso \ + --prefix="$INSTALL_DIR" \ + --openssldir="$INSTALL_DIR/ssl" + + run_quiet make -j"$NCPU" + run_quiet make install_sw + + echo " Installed to $INSTALL_DIR" +} + +# --- Build device (arm64) --- + +build_openssl_slice "iphoneos" "arm64" "ios64-xcrun" + +# --- Build simulator (arm64) --- + +# OpenSSL doesn't have a direct simulator target. +# Use iossimulator-xcrun with explicit arch. +SIMULATOR_SRC="$BUILD_DIR/openssl-iphonesimulator-arm64" +cp -R "$OPENSSL_SRC" "$SIMULATOR_SRC" +cd "$SIMULATOR_SRC" + +SIMULATOR_SDK=$(xcrun --sdk iphonesimulator --show-sdk-path) +SIMULATOR_INSTALL="$BUILD_DIR/install-iphonesimulator-arm64" + +export IPHONEOS_DEPLOYMENT_TARGET="$IOS_DEPLOY_TARGET" + +echo "=> Building OpenSSL for iphonesimulator (arm64)..." + +run_quiet ./Configure iossimulator-xcrun \ + no-shared no-tests no-apps no-docs no-engine no-async \ + no-comp no-dtls no-psk no-srp no-ssl3 no-dso \ + --prefix="$SIMULATOR_INSTALL" \ + --openssldir="$SIMULATOR_INSTALL/ssl" + +run_quiet make -j"$NCPU" +run_quiet make install_sw + +echo " Installed to $SIMULATOR_INSTALL" + +# --- Create xcframework --- + +DEVICE_DIR="$BUILD_DIR/install-iphoneos-arm64" +SIM_DIR="$SIMULATOR_INSTALL" + +# Remove old xcframework if exists +rm -rf "$LIBS_DIR/OpenSSL.xcframework" + +echo "=> Creating OpenSSL.xcframework..." + +# xcframework needs a single library per platform variant. +# Merge libssl + libcrypto into one fat archive per slice for simplicity, +# OR create separate xcframeworks. We'll keep them separate in the xcframework +# by creating a temporary merged lib. + +# Device: merge libssl + libcrypto +mkdir -p "$BUILD_DIR/merged-device" +cp "$DEVICE_DIR/lib/libssl.a" "$BUILD_DIR/merged-device/" +cp "$DEVICE_DIR/lib/libcrypto.a" "$BUILD_DIR/merged-device/" +cp -R "$DEVICE_DIR/include" "$BUILD_DIR/merged-device/" + +# Simulator: merge +mkdir -p "$BUILD_DIR/merged-sim" +cp "$SIM_DIR/lib/libssl.a" "$BUILD_DIR/merged-sim/" +cp "$SIM_DIR/lib/libcrypto.a" "$BUILD_DIR/merged-sim/" +cp -R "$SIM_DIR/include" "$BUILD_DIR/merged-sim/" + +# Create two xcframeworks (one per lib) +xcodebuild -create-xcframework \ + -library "$BUILD_DIR/merged-device/libssl.a" \ + -headers "$BUILD_DIR/merged-device/include" \ + -library "$BUILD_DIR/merged-sim/libssl.a" \ + -headers "$BUILD_DIR/merged-sim/include" \ + -output "$LIBS_DIR/OpenSSL-SSL.xcframework" + +xcodebuild -create-xcframework \ + -library "$BUILD_DIR/merged-device/libcrypto.a" \ + -library "$BUILD_DIR/merged-sim/libcrypto.a" \ + -output "$LIBS_DIR/OpenSSL-Crypto.xcframework" + +echo "" +echo "OpenSSL $OPENSSL_VERSION for iOS built successfully!" +echo " $LIBS_DIR/OpenSSL-SSL.xcframework" +echo " $LIBS_DIR/OpenSSL-Crypto.xcframework" + +# --- Verify --- + +echo "" +echo "=> Verifying device slice..." +lipo -info "$BUILD_DIR/merged-device/libssl.a" +otool -l "$BUILD_DIR/merged-device/libssl.a" | grep -A4 "LC_BUILD_VERSION" | head -5 + +echo "" +echo "=> Verifying simulator slice..." +lipo -info "$BUILD_DIR/merged-sim/libssl.a" +otool -l "$BUILD_DIR/merged-sim/libssl.a" | grep -A4 "LC_BUILD_VERSION" | head -5 + +echo "" +echo "Done!" From 3935d3f36612f248d2964949810c8990c69db0cf Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 2 Apr 2026 19:45:30 +0700 Subject: [PATCH 25/61] =?UTF-8?q?feat:=20iOS=20cross-compile=20scripts=20?= =?UTF-8?q?=E2=80=94=20OpenSSL,=20hiredis,=20libpq,=20MariaDB=20xcframewor?= =?UTF-8?q?ks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/ios/build-libpq-ios.sh | 362 ++++++++++++++++++++++--------- scripts/ios/build-mariadb-ios.sh | 19 +- 2 files changed, 273 insertions(+), 108 deletions(-) diff --git a/scripts/ios/build-libpq-ios.sh b/scripts/ios/build-libpq-ios.sh index 3a059075f..929093182 100755 --- a/scripts/ios/build-libpq-ios.sh +++ b/scripts/ios/build-libpq-ios.sh @@ -1,17 +1,14 @@ #!/bin/bash set -eo pipefail -# Build static libpq (PostgreSQL) for iOS → xcframework +# Build static libpq for iOS using xcodebuild/xcrun clang directly. +# No autotools configure needed — compile source files directly. # # Requires: OpenSSL xcframework already built -# # Produces: Libs/ios/LibPQ.xcframework/ -# -# Usage: -# ./scripts/ios/build-libpq-ios.sh PG_VERSION="17.4" -PG_SHA256="1b9e50ed65ef9e4e4ed3c073cb9950a8e38e94a2e7e3c5e4b5b56e585e104248" +PG_SHA256="c4605b73fea11963406699f949b966e5d173a7ee0ccaef8938dec0ca8a995fe7" IOS_DEPLOY_TARGET="17.0" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -20,18 +17,6 @@ LIBS_DIR="$PROJECT_DIR/Libs/ios" BUILD_DIR="$(mktemp -d)" NCPU=$(sysctl -n hw.ncpu) -run_quiet() { - local logfile - logfile=$(mktemp) - if ! "$@" > "$logfile" 2>&1; then - echo "FAILED: $*" - tail -50 "$logfile" - rm -f "$logfile" - return 1 - fi - rm -f "$logfile" -} - cleanup() { echo " Cleaning up build directory..." rm -rf "$BUILD_DIR" @@ -41,119 +26,303 @@ trap cleanup EXIT echo "Building static libpq (PostgreSQL $PG_VERSION) for iOS" echo " Build dir: $BUILD_DIR" +# --- Download & extract --- + +echo "=> Downloading PostgreSQL $PG_VERSION..." +curl -f#SL "https://ftp.postgresql.org/pub/source/v$PG_VERSION/postgresql-$PG_VERSION.tar.bz2" \ + -o "$BUILD_DIR/postgresql.tar.bz2" +echo "$PG_SHA256 $BUILD_DIR/postgresql.tar.bz2" | shasum -a 256 -c - +tar xjpf "$BUILD_DIR/postgresql.tar.bz2" -C "$BUILD_DIR" +PG_SRC="$BUILD_DIR/postgresql-$PG_VERSION" +echo " Done." + +# --- Generate pg_config.h and other headers on macOS host --- +# Run configure natively with minimal PATH to avoid shell slowness + +echo "=> Generating config headers (native configure)..." +NATIVE_DIR="$BUILD_DIR/pg-native" +cp -R "$PG_SRC" "$NATIVE_DIR" +cd "$NATIVE_DIR" + +PATH="/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/homebrew/bin" \ + ./configure \ + --without-readline --without-icu --without-gssapi \ + --without-zstd --without-ssl > "$BUILD_DIR/configure.log" 2>&1 & +CONFIGURE_PID=$! + +# Wait up to 120 seconds +for i in $(seq 1 120); do + if ! kill -0 $CONFIGURE_PID 2>/dev/null; then + break + fi + sleep 1 +done + +if kill -0 $CONFIGURE_PID 2>/dev/null; then + echo " Configure timed out — generating headers manually..." + kill $CONFIGURE_PID 2>/dev/null || true + wait $CONFIGURE_PID 2>/dev/null || true + + # Generate pg_config.h manually + mkdir -p "$NATIVE_DIR/src/include" + cat > "$NATIVE_DIR/src/include/pg_config.h" << 'PGCFG' +#define PG_MAJORVERSION "17" +#define PG_MAJORVERSION_NUM 17 +#define PG_MINORVERSION_NUM 4 +#define PG_VERSION "17.4" +#define PG_VERSION_NUM 170004 +#define BLCKSZ 8192 +#define XLOG_BLCKSZ 8192 +#define RELSEG_SIZE 131072 +#define DEF_PGPORT 5432 +#define DEF_PGPORT_STR "5432" +#define MAXIMUM_ALIGNOF 8 +#define SIZEOF_VOID_P 8 +#define SIZEOF_SIZE_T 8 +#define SIZEOF_LONG 8 +#define SIZEOF_OFF_T 8 +#define FLOAT8PASSBYVAL 1 +#define HAVE_LONG_INT_64 1 +#define INT64_IS_BUSTED 0 +#define PG_INT64_TYPE long int +#define HAVE_STDBOOL_H 1 +#define HAVE_STDINT_H 1 +#define HAVE_INTTYPES_H 1 +#define HAVE_STRINGS_H 1 +#define HAVE_STRING_H 1 +#define HAVE_UNISTD_H 1 +#define HAVE_SYS_TYPES_H 1 +#define HAVE_SYS_STAT_H 1 +#define HAVE_MEMORY_H 1 +#define HAVE_NETINET_IN_H 1 +#define HAVE_NETDB_H 1 +#define HAVE_SYS_SOCKET_H 1 +#define HAVE_SYS_UN_H 1 +#define HAVE_SYS_SELECT_H 1 +#define HAVE_POLL_H 1 +#define HAVE_SYS_POLL_H 1 +#define HAVE_TERMIOS_H 1 +#define HAVE_DLFCN_H 1 +#define HAVE_GETADDRINFO 1 +#define HAVE_GETHOSTBYNAME_R 0 +#define HAVE_INET_ATON 1 +#define HAVE_STRERROR_R 1 +#define HAVE_STRLCAT 1 +#define HAVE_STRLCPY 1 +#define HAVE_STRNLEN 1 +#define HAVE_STRSIGNAL 1 +#define HAVE_PREAD 1 +#define HAVE_PWRITE 1 +#define HAVE_MKDTEMP 1 +#define HAVE_RANDOM 1 +#define HAVE_SRANDOM 1 +#define HAVE_DLOPEN 1 +#define HAVE_FDATASYNC 0 +#define HAVE_WCTYPE_H 1 +#define HAVE_LANGINFO_H 1 +#define HAVE_LOCALE_T 1 +#define ENABLE_THREAD_SAFETY 1 +#define USE_OPENSSL 1 +#define HAVE_OPENSSL_INIT_SSL 1 +#define HAVE_BIO_METH_NEW 1 +#define HAVE_HMAC_CTX_NEW 1 +#define HAVE_HMAC_CTX_FREE 1 +#define HAVE_SSL_CTX_SET_CERT_CB 1 +#define HAVE_X509_GET_SIGNATURE_NID 1 +#define HAVE_STRUCT_SOCKADDR_STORAGE 1 +#define HAVE_STRUCT_SOCKADDR_STORAGE_SS_LEN 1 +#define HAVE_STRUCT_SOCKADDR_STORAGE_SS_FAMILY 1 +#define ACCEPT_TYPE_ARG1 int +#define ACCEPT_TYPE_ARG2 struct sockaddr * +#define ACCEPT_TYPE_ARG3 socklen_t +#define ACCEPT_TYPE_RETURN int +PGCFG + + cat > "$NATIVE_DIR/src/include/pg_config_ext.h" << 'PGCFGEXT' +#define PG_INT64_TYPE long int +PGCFGEXT + + cat > "$NATIVE_DIR/src/include/pg_config_os.h" << 'PGCFGOS' +/* Darwin (macOS/iOS) */ +#define HAVE_DECL_STRLCAT 1 +#define HAVE_DECL_STRLCPY 1 +PGCFGOS + + cat > "$NATIVE_DIR/src/include/pg_config_paths.h" << 'PGPATHS' +#define PGBINDIR "/usr/local/pgsql/bin" +#define PGSHAREDIR "/usr/local/pgsql/share" +#define SYSCONFDIR "/usr/local/pgsql/etc" +#define INCLUDEDIR "/usr/local/pgsql/include" +#define PKGINCLUDEDIR "/usr/local/pgsql/include" +#define INCLUDEDIRSERVER "/usr/local/pgsql/include/server" +#define LIBDIR "/usr/local/pgsql/lib" +#define PKGLIBDIR "/usr/local/pgsql/lib" +#define LOCALEDIR "/usr/local/pgsql/share/locale" +#define DOCDIR "/usr/local/pgsql/share/doc" +#define HTMLDIR "/usr/local/pgsql/share/doc" +#define MANDIR "/usr/local/pgsql/share/man" +PGPATHS + +else + echo " Native configure completed." +fi + # --- Locate OpenSSL --- -setup_openssl_prefix() { - local PLATFORM_KEY=$1 # ios-arm64 or ios-arm64-simulator - local PREFIX_DIR="$BUILD_DIR/openssl-$PLATFORM_KEY" +setup_openssl() { + local PLATFORM_KEY=$1 + local PREFIX="$BUILD_DIR/openssl-$PLATFORM_KEY" local SSL_LIB=$(find "$LIBS_DIR/OpenSSL-SSL.xcframework" -path "*$PLATFORM_KEY*/libssl.a" | head -1) local CRYPTO_LIB=$(find "$LIBS_DIR/OpenSSL-Crypto.xcframework" -path "*$PLATFORM_KEY*/libcrypto.a" | head -1) local HEADERS=$(find "$LIBS_DIR/OpenSSL-SSL.xcframework" -path "*$PLATFORM_KEY*/Headers" -type d | head -1) if [ -z "$SSL_LIB" ] || [ -z "$CRYPTO_LIB" ]; then - echo "ERROR: OpenSSL not found for $PLATFORM_KEY. Run build-openssl-ios.sh first." + echo "ERROR: OpenSSL not found for $PLATFORM_KEY" exit 1 fi - mkdir -p "$PREFIX_DIR/lib" "$PREFIX_DIR/include" - cp "$SSL_LIB" "$PREFIX_DIR/lib/" - cp "$CRYPTO_LIB" "$PREFIX_DIR/lib/" - [ -d "$HEADERS" ] && cp -R "$HEADERS/openssl" "$PREFIX_DIR/include/" 2>/dev/null || true + mkdir -p "$PREFIX/lib" "$PREFIX/include" + cp "$SSL_LIB" "$PREFIX/lib/" + cp "$CRYPTO_LIB" "$PREFIX/lib/" + [ -d "$HEADERS" ] && cp -R "$HEADERS/openssl" "$PREFIX/include/" 2>/dev/null || true - OPENSSL_PREFIX="$PREFIX_DIR" + OPENSSL_PREFIX="$PREFIX" } -# --- Download PostgreSQL --- - -echo "=> Downloading PostgreSQL $PG_VERSION..." -curl -fSL "https://ftp.postgresql.org/pub/source/v$PG_VERSION/postgresql-$PG_VERSION.tar.bz2" \ - -o "$BUILD_DIR/postgresql.tar.bz2" -echo "$PG_SHA256 $BUILD_DIR/postgresql.tar.bz2" | shasum -a 256 -c - > /dev/null - -tar xjf "$BUILD_DIR/postgresql.tar.bz2" -C "$BUILD_DIR" -PG_SRC="$BUILD_DIR/postgresql-$PG_VERSION" - -# --- Build function --- +# --- Compile libpq for one iOS slice --- -build_libpq_slice() { +build_slice() { local SDK_NAME=$1 # iphoneos or iphonesimulator - local ARCH=$2 # arm64 - local PLATFORM_KEY=$3 # ios-arm64 or ios-arm64-simulator + local ARCH=$2 + local PLATFORM_KEY=$3 local INSTALL_DIR="$BUILD_DIR/install-$SDK_NAME-$ARCH" - echo "=> Building libpq for $SDK_NAME ($ARCH)..." + echo "=> Compiling libpq for $SDK_NAME ($ARCH)..." - setup_openssl_prefix "$PLATFORM_KEY" + setup_openssl "$PLATFORM_KEY" - local SDK_PATH - SDK_PATH=$(xcrun --sdk "$SDK_NAME" --show-sdk-path) + local SDK=$(xcrun --sdk "$SDK_NAME" --show-sdk-path) + local CC=$(xcrun --sdk "$SDK_NAME" -f cc) + local AR=$(xcrun --sdk "$SDK_NAME" -f ar) + local RANLIB=$(xcrun --sdk "$SDK_NAME" -f ranlib) - local SRC_COPY="$BUILD_DIR/pg-$SDK_NAME-$ARCH" - cp -R "$PG_SRC" "$SRC_COPY" - cd "$SRC_COPY" - - local HOST="aarch64-apple-darwin" - local TARGET_FLAG="" + local TARGET_FLAG if [ "$SDK_NAME" = "iphonesimulator" ]; then TARGET_FLAG="-target arm64-apple-ios${IOS_DEPLOY_TARGET}-simulator" else TARGET_FLAG="-target arm64-apple-ios${IOS_DEPLOY_TARGET}" fi - export IPHONEOS_DEPLOYMENT_TARGET="$IOS_DEPLOY_TARGET" - - run_quiet env \ - CFLAGS="-arch $ARCH -isysroot $SDK_PATH $TARGET_FLAG -mios-version-min=$IOS_DEPLOY_TARGET -Wno-unguarded-availability-new -I$OPENSSL_PREFIX/include" \ - LDFLAGS="-arch $ARCH -isysroot $SDK_PATH $TARGET_FLAG -L$OPENSSL_PREFIX/lib" \ - ac_cv_func_strchrnul=yes \ - ./configure \ - --prefix="$INSTALL_DIR" \ - --host="$HOST" \ - --with-ssl=openssl \ - --without-readline \ - --without-icu \ - --without-gssapi + local CFLAGS="-arch $ARCH -isysroot $SDK $TARGET_FLAG -mios-version-min=$IOS_DEPLOY_TARGET -O2" + local PG_INCLUDES="-I$NATIVE_DIR/src/include -I$NATIVE_DIR/src/include/port/darwin -I$NATIVE_DIR/src/interfaces/libpq -I$NATIVE_DIR/src/port -I$OPENSSL_PREFIX/include -I$NATIVE_DIR/src/common" + + local OBJ_DIR="$BUILD_DIR/obj-$SDK_NAME-$ARCH" + mkdir -p "$OBJ_DIR" "$INSTALL_DIR/lib" "$INSTALL_DIR/include" + + # --- libpq source files --- + local LIBPQ_SRCS=( + src/interfaces/libpq/fe-auth.c + src/interfaces/libpq/fe-auth-scram.c + src/interfaces/libpq/fe-connect.c + src/interfaces/libpq/fe-exec.c + src/interfaces/libpq/fe-lobj.c + src/interfaces/libpq/fe-misc.c + src/interfaces/libpq/fe-print.c + src/interfaces/libpq/fe-protocol3.c + src/interfaces/libpq/fe-secure.c + src/interfaces/libpq/fe-secure-openssl.c + src/interfaces/libpq/fe-trace.c + src/interfaces/libpq/legacy-pqsignal.c + src/interfaces/libpq/libpq-events.c + src/interfaces/libpq/pqexpbuffer.c + src/interfaces/libpq/fe-secure-common.c + src/interfaces/libpq/fe-cancel.c + ) + + # --- Common library source files needed by libpq --- + local COMMON_SRCS=( + src/common/base64.c + src/common/cryptohash.c + src/common/cryptohash_openssl.c + src/common/hmac.c + src/common/hmac_openssl.c + src/common/ip.c + src/common/link-canary.c + src/common/md5_common.c + src/common/scram-common.c + src/common/saslprep.c + src/common/string.c + src/common/stringinfo.c + src/common/unicode_norm.c + src/common/wchar.c + src/common/encnames.c + src/common/fe_memutils.c + src/common/logging.c + src/common/percentrepl.c + src/common/psprintf.c + ) + + # --- Port library source files --- + local PORT_SRCS=( + src/port/chklocale.c + src/port/inet_net_ntop.c + src/port/noblock.c + src/port/pg_strong_random.c + src/port/pgstrsignal.c + src/port/snprintf.c + src/port/strerror.c + src/port/thread.c + src/port/path.c + ) + + cd "$NATIVE_DIR" + + # Compile all source files + local ALL_OBJS=() + for src in "${LIBPQ_SRCS[@]}" "${COMMON_SRCS[@]}" "${PORT_SRCS[@]}"; do + local obj_name=$(basename "${src%.c}.o") + if [ -f "$src" ]; then + $CC $CFLAGS $PG_INCLUDES -DFRONTEND -c "$src" -o "$OBJ_DIR/$obj_name" 2>/dev/null || { + echo " WARNING: skipped $src" + continue + } + ALL_OBJS+=("$OBJ_DIR/$obj_name") + fi + done # strchrnul compat - cat > src/port/strchrnul_compat.c << 'COMPAT_EOF' + cat > "$OBJ_DIR/strchrnul_compat.c" << 'EOF' #include char *strchrnul(const char *s, int c) { while (*s && *s != (char)c) s++; return (char *)s; } -COMPAT_EOF - - run_quiet make -C src/include -j"$NCPU" - run_quiet make -C src/common -j"$NCPU" - run_quiet make -C src/port -j"$NCPU" - run_quiet make -C src/interfaces/libpq all-static-lib -j"$NCPU" +EOF + $CC $CFLAGS -c "$OBJ_DIR/strchrnul_compat.c" -o "$OBJ_DIR/strchrnul_compat.o" + ALL_OBJS+=("$OBJ_DIR/strchrnul_compat.o") - # Add strchrnul compat - xcrun --sdk "$SDK_NAME" cc -arch "$ARCH" -isysroot "$SDK_PATH" $TARGET_FLAG \ - -c -o src/port/strchrnul_compat.o src/port/strchrnul_compat.c - run_quiet ar rs src/port/libpgport_shlib.a src/port/strchrnul_compat.o + # Create static library + $AR rcs "$INSTALL_DIR/lib/libpq.a" "${ALL_OBJS[@]}" + $RANLIB "$INSTALL_DIR/lib/libpq.a" - mkdir -p "$INSTALL_DIR/lib" "$INSTALL_DIR/include" - cp src/interfaces/libpq/libpq.a "$INSTALL_DIR/lib/" - cp src/common/libpgcommon_shlib.a "$INSTALL_DIR/lib/libpgcommon.a" - cp src/port/libpgport_shlib.a "$INSTALL_DIR/lib/libpgport.a" + local OBJ_COUNT=${#ALL_OBJS[@]} + echo " Compiled $OBJ_COUNT objects → libpq.a" # Copy headers - cp src/interfaces/libpq/libpq-fe.h "$INSTALL_DIR/include/" - cp src/include/libpq/libpq-fs.h "$INSTALL_DIR/include/" 2>/dev/null || true - cp src/include/postgres_ext.h "$INSTALL_DIR/include/" - cp src/include/pg_config_ext.h "$INSTALL_DIR/include/" 2>/dev/null || true + cp "$NATIVE_DIR/src/interfaces/libpq/libpq-fe.h" "$INSTALL_DIR/include/" + cp "$NATIVE_DIR/src/include/postgres_ext.h" "$INSTALL_DIR/include/" + cp "$NATIVE_DIR/src/include/pg_config_ext.h" "$INSTALL_DIR/include/" 2>/dev/null || true echo " Installed to $INSTALL_DIR" } -# --- Build slices --- +# --- Build both slices --- -build_libpq_slice "iphoneos" "arm64" "ios-arm64" -build_libpq_slice "iphonesimulator" "arm64" "ios-arm64-simulator" +build_slice "iphoneos" "arm64" "ios-arm64" +build_slice "iphonesimulator" "arm64" "ios-arm64-simulator" -# --- Create xcframeworks --- +# --- Create xcframework --- DEVICE_DIR="$BUILD_DIR/install-iphoneos-arm64" SIM_DIR="$BUILD_DIR/install-iphonesimulator-arm64" @@ -162,22 +331,10 @@ rm -rf "$LIBS_DIR/LibPQ.xcframework" echo "=> Creating LibPQ.xcframework..." -# Merge libpq + libpgcommon + libpgport into single archive per slice -for DIR in "$DEVICE_DIR" "$SIM_DIR"; do - mkdir -p "$DIR/merged" - cp "$DIR/lib/libpq.a" "$DIR/merged/" - # Extract and re-archive pgcommon + pgport into libpq - cd "$DIR/merged" - ar x "$DIR/lib/libpgcommon.a" - ar x "$DIR/lib/libpgport.a" - ar rs libpq.a *.o 2>/dev/null - rm -f *.o -done - xcodebuild -create-xcframework \ - -library "$DEVICE_DIR/merged/libpq.a" \ + -library "$DEVICE_DIR/lib/libpq.a" \ -headers "$DEVICE_DIR/include" \ - -library "$SIM_DIR/merged/libpq.a" \ + -library "$SIM_DIR/lib/libpq.a" \ -headers "$SIM_DIR/include" \ -output "$LIBS_DIR/LibPQ.xcframework" @@ -185,8 +342,7 @@ echo "" echo "libpq (PostgreSQL $PG_VERSION) for iOS built successfully!" echo " $LIBS_DIR/LibPQ.xcframework" -# --- Verify --- - +# Verify echo "" echo "=> Verifying device slice..." lipo -info "$DEVICE_DIR/lib/libpq.a" diff --git a/scripts/ios/build-mariadb-ios.sh b/scripts/ios/build-mariadb-ios.sh index 98814c01c..7272bb9aa 100755 --- a/scripts/ios/build-mariadb-ios.sh +++ b/scripts/ios/build-mariadb-ios.sh @@ -101,7 +101,10 @@ build_mariadb_slice() { -DCMAKE_OSX_SYSROOT="$SDK_PATH" \ -DCMAKE_INSTALL_PREFIX="$INSTALL_DIR" \ -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \ + -DCMAKE_C_FLAGS="-Wno-default-const-init-var-unsafe -Wno-inline-asm -Wno-error=inline-asm" \ -DBUILD_SHARED_LIBS=OFF \ + -DWITH_EXTERNAL_ZLIB=ON \ -DWITH_SSL=OPENSSL \ -DOPENSSL_ROOT_DIR="$OPENSSL_PREFIX" \ -DOPENSSL_SSL_LIBRARY="$OPENSSL_PREFIX/lib/libssl.a" \ @@ -119,8 +122,14 @@ build_mariadb_slice() { -DCLIENT_PLUGIN_PVIO_NPIPE=OFF \ -DCLIENT_PLUGIN_PVIO_SHMEM=OFF - run_quiet cmake --build . --config Release -j"$NCPU" - run_quiet cmake --install . --config Release + cmake --build . --target mariadb_obj -j"$NCPU" 2>&1 + cmake --build . --target mariadbclient -j"$NCPU" 2>&1 + + # Copy static lib and headers directly (cmake install fails looking for .so plugins) + mkdir -p "$INSTALL_DIR/lib" "$INSTALL_DIR/include/mariadb" + cp libmariadb/libmariadbclient.a "$INSTALL_DIR/lib/libmariadb.a" + cp "$SRC_COPY/include/"*.h "$INSTALL_DIR/include/mariadb/" 2>/dev/null || true + cp "$BUILD/include/"*.h "$INSTALL_DIR/include/mariadb/" 2>/dev/null || true echo " Installed to $INSTALL_DIR" } @@ -138,9 +147,9 @@ SIM_DIR="$BUILD_DIR/install-iphonesimulator-arm64" rm -rf "$LIBS_DIR/MariaDB.xcframework" # Find the actual .a file (may be in lib/ or lib/mariadb/) -DEVICE_LIB=$(find "$DEVICE_DIR" -name "libmariadb.a" | head -1) -SIM_LIB=$(find "$SIM_DIR" -name "libmariadb.a" | head -1) -DEVICE_HEADERS=$(find "$DEVICE_DIR" -name "mysql.h" -exec dirname {} \; | head -1) +DEVICE_LIB=$(find "$DEVICE_DIR" -name "libmariadb.a" -o -name "libmariadbclient.a" | head -1) +SIM_LIB=$(find "$SIM_DIR" -name "libmariadb.a" -o -name "libmariadbclient.a" | head -1) +DEVICE_HEADERS=$(find "$DEVICE_DIR" -path "*/mariadb/*.h" -exec dirname {} \; | sort -u | head -1) if [ -z "$DEVICE_LIB" ] || [ -z "$SIM_LIB" ]; then echo "ERROR: libmariadb.a not found in install directories" From ca10b83dc4c0fdcea998937932e6889d12d52b04 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 2 Apr 2026 19:56:02 +0700 Subject: [PATCH 26/61] =?UTF-8?q?refactor:=20fix=2013=20code=20quality=20i?= =?UTF-8?q?ssues=20=E2=80=94=20proper=20SQL=20quoting,=20secure=20storage,?= =?UTF-8?q?=20build=20script=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TableProDatabase/ConnectionSession.swift | 3 + TableProMobile/TableProMobile/AppState.swift | 26 +++++-- .../TableProMobile/Helpers/SQLBuilder.swift | 74 +++++++++++++++++++ .../TableProMobile/Helpers/SQLHelper.swift | 48 ------------ .../TableProMobile/TableProMobileApp.swift | 11 +-- .../Views/ConnectionFormView.swift | 12 ++- .../Views/ConnectionListView.swift | 3 +- .../Views/DataBrowserView.swift | 16 +++- .../TableProMobile/Views/InsertRowView.swift | 4 +- .../TableProMobile/Views/RowDetailView.swift | 6 +- .../TableProMobile/Views/TableListView.swift | 7 +- scripts/ios/build-hiredis-ios.sh | 7 -- scripts/ios/build-libpq-ios.sh | 22 ++++-- scripts/ios/build-mariadb-ios.sh | 4 +- 14 files changed, 153 insertions(+), 90 deletions(-) create mode 100644 TableProMobile/TableProMobile/Helpers/SQLBuilder.swift delete mode 100644 TableProMobile/TableProMobile/Helpers/SQLHelper.swift diff --git a/Packages/TableProCore/Sources/TableProDatabase/ConnectionSession.swift b/Packages/TableProCore/Sources/TableProDatabase/ConnectionSession.swift index ef06ae73e..e5f8c9cad 100644 --- a/Packages/TableProCore/Sources/TableProDatabase/ConnectionSession.swift +++ b/Packages/TableProCore/Sources/TableProDatabase/ConnectionSession.swift @@ -1,6 +1,9 @@ import Foundation import TableProModels +/// Note: Views hold a snapshot of this struct. Mutable fields (activeDatabase, status) +/// are only updated through ConnectionManager.updateSession and should be re-fetched +/// from the manager when needed rather than read from a held copy. public struct ConnectionSession: Sendable { public let connectionId: UUID public let driver: any DatabaseDriver diff --git a/TableProMobile/TableProMobile/AppState.swift b/TableProMobile/TableProMobile/AppState.swift index 2d512b117..c6a7fd602 100644 --- a/TableProMobile/TableProMobile/AppState.swift +++ b/TableProMobile/TableProMobile/AppState.swift @@ -42,29 +42,39 @@ final class AppState { try? connectionManager.deletePassword(for: connection.id) storage.save(connections) } - - func disconnectAll() { - Task { - await connectionManager.disconnectAll() - } - } } // MARK: - Persistence private struct ConnectionPersistence { - private let key = "com.TablePro.Mobile.connections" + private var fileURL: URL { + let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + .appendingPathComponent("TableProMobile", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir.appendingPathComponent("connections.json") + } func save(_ connections: [DatabaseConnection]) { guard let data = try? JSONEncoder().encode(connections) else { return } - UserDefaults.standard.set(data, forKey: key) + try? data.write(to: fileURL, options: [.atomic, .completeFileProtection]) } func load() -> [DatabaseConnection] { + guard let data = try? Data(contentsOf: fileURL), + let connections = try? JSONDecoder().decode([DatabaseConnection].self, from: data) else { + return migrateFromUserDefaults() + } + return connections + } + + private func migrateFromUserDefaults() -> [DatabaseConnection] { + let key = "com.TablePro.Mobile.connections" guard let data = UserDefaults.standard.data(forKey: key), let connections = try? JSONDecoder().decode([DatabaseConnection].self, from: data) else { return [] } + save(connections) + UserDefaults.standard.removeObject(forKey: key) return connections } } diff --git a/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift b/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift new file mode 100644 index 000000000..a4d3e4098 --- /dev/null +++ b/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift @@ -0,0 +1,74 @@ +// +// SQLBuilder.swift +// TableProMobile +// + +import Foundation +import TableProModels + +enum SQLBuilder { + static func quoteIdentifier(_ name: String, for type: DatabaseType) -> String { + switch type { + case .mysql, .mariadb: + return "`\(name.replacingOccurrences(of: "`", with: "``"))`" + case .postgresql, .redshift: + return "\"\(name.replacingOccurrences(of: "\"", with: "\"\""))\"" + default: + return "\"\(name.replacingOccurrences(of: "\"", with: "\"\""))\"" + } + } + + static func escapeString(_ value: String) -> String { + value.replacingOccurrences(of: "'", with: "''") + } + + static func buildSelect(table: String, type: DatabaseType, limit: Int, offset: Int) -> String { + let quoted = quoteIdentifier(table, for: type) + return "SELECT * FROM \(quoted) LIMIT \(limit) OFFSET \(offset)" + } + + static func buildDelete( + table: String, + type: DatabaseType, + primaryKeys: [(column: String, value: String)] + ) -> String { + let quotedTable = quoteIdentifier(table, for: type) + let where_ = primaryKeys.map { + "\(quoteIdentifier($0.column, for: type)) = '\(escapeString($0.value))'" + }.joined(separator: " AND ") + return "DELETE FROM \(quotedTable) WHERE \(where_)" + } + + static func buildUpdate( + table: String, + type: DatabaseType, + changes: [(column: String, value: String?)], + primaryKeys: [(column: String, value: String)] + ) -> String { + let quotedTable = quoteIdentifier(table, for: type) + let set_ = changes.map { col, val in + let qcol = quoteIdentifier(col, for: type) + if let val { return "\(qcol) = '\(escapeString(val))'" } + return "\(qcol) = NULL" + }.joined(separator: ", ") + let where_ = primaryKeys.map { + "\(quoteIdentifier($0.column, for: type)) = '\(escapeString($0.value))'" + }.joined(separator: " AND ") + return "UPDATE \(quotedTable) SET \(set_) WHERE \(where_)" + } + + static func buildInsert( + table: String, + type: DatabaseType, + columns: [String], + values: [String?] + ) -> String { + let quotedTable = quoteIdentifier(table, for: type) + let cols = columns.map { quoteIdentifier($0, for: type) }.joined(separator: ", ") + let vals = values.map { val in + if let val { return "'\(escapeString(val))'" } + return "NULL" + }.joined(separator: ", ") + return "INSERT INTO \(quotedTable) (\(cols)) VALUES (\(vals))" + } +} diff --git a/TableProMobile/TableProMobile/Helpers/SQLHelper.swift b/TableProMobile/TableProMobile/Helpers/SQLHelper.swift deleted file mode 100644 index 2c8164869..000000000 --- a/TableProMobile/TableProMobile/Helpers/SQLHelper.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// SQLHelper.swift -// TableProMobile -// - -import Foundation - -enum SQLHelper { - static func buildDelete( - table: String, - primaryKeys: [(column: String, value: String)] - ) -> String { - let whereClause = primaryKeys.map { "`\($0.column)` = '\(escape($0.value))'" } - .joined(separator: " AND ") - return "DELETE FROM `\(table)` WHERE \(whereClause)" - } - - static func buildUpdate( - table: String, - changes: [(column: String, value: String?)], - primaryKeys: [(column: String, value: String)] - ) -> String { - let setClauses = changes.map { col, val in - if let val { return "`\(col)` = '\(escape(val))'" } - return "`\(col)` = NULL" - }.joined(separator: ", ") - let whereClause = primaryKeys.map { "`\($0.column)` = '\(escape($0.value))'" } - .joined(separator: " AND ") - return "UPDATE `\(table)` SET \(setClauses) WHERE \(whereClause)" - } - - static func buildInsert( - table: String, - columns: [String], - values: [String?] - ) -> String { - let cols = columns.map { "`\($0)`" }.joined(separator: ", ") - let vals = values.map { val in - if let val { return "'\(escape(val))'" } - return "NULL" - }.joined(separator: ", ") - return "INSERT INTO `\(table)` (\(cols)) VALUES (\(vals))" - } - - static func escape(_ value: String) -> String { - value.replacingOccurrences(of: "'", with: "''") - } -} diff --git a/TableProMobile/TableProMobile/TableProMobileApp.swift b/TableProMobile/TableProMobile/TableProMobileApp.swift index 78b986200..753ad6085 100644 --- a/TableProMobile/TableProMobile/TableProMobileApp.swift +++ b/TableProMobile/TableProMobile/TableProMobileApp.swift @@ -18,13 +18,10 @@ struct TableProMobileApp: App { .environment(appState) } .onChange(of: scenePhase) { _, phase in - switch phase { - case .background: - appState.disconnectAll() - case .active: - break - default: - break + if phase == .background { + Task { + await appState.connectionManager.disconnectAll() + } } } } diff --git a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift index eee22be44..406bf8a2d 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift @@ -291,19 +291,23 @@ struct ConnectionFormView: View { isTesting = true testResult = nil - let connection = buildConnection() + let tempId = UUID() + var testConn = buildConnection() + testConn.id = tempId + if !password.isEmpty { - try? appState.connectionManager.storePassword(password, for: connection.id) + try? appState.connectionManager.storePassword(password, for: tempId) } do { - _ = try await appState.connectionManager.connect(connection) - await appState.connectionManager.disconnect(connection.id) + _ = try await appState.connectionManager.connect(testConn) + await appState.connectionManager.disconnect(tempId) testResult = TestResult(success: true, message: "Connection successful") } catch { testResult = TestResult(success: false, message: error.localizedDescription) } + try? appState.connectionManager.deletePassword(for: tempId) isTesting = false } diff --git a/TableProMobile/TableProMobile/Views/ConnectionListView.swift b/TableProMobile/TableProMobile/Views/ConnectionListView.swift index 977d4ecf1..0909f687a 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionListView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionListView.swift @@ -44,8 +44,7 @@ struct ConnectionListView: View { } .sheet(item: $editingConnection) { connection in ConnectionFormView(editing: connection) { updated in - appState.removeConnection(connection) - appState.addConnection(updated) + appState.updateConnection(updated) editingConnection = nil } } diff --git a/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/TableProMobile/TableProMobile/Views/DataBrowserView.swift index 446ebefed..135d4a95f 100644 --- a/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -92,6 +92,7 @@ struct DataBrowserView: View { table: table, columnDetails: columnDetails, session: session, + databaseType: connection.type, onInserted: { Task { await loadData() } } @@ -125,6 +126,7 @@ struct DataBrowserView: View { table: table, session: session, columnDetails: columnDetails, + databaseType: connection.type, onSaved: { Task { await loadData() } } @@ -188,12 +190,17 @@ struct DataBrowserView: View { pagination.reset() do { - let query = "SELECT * FROM `\(table.name)` LIMIT \(pagination.pageSize) OFFSET \(pagination.currentOffset)" + let query = SQLBuilder.buildSelect( + table: table.name, type: connection.type, + limit: pagination.pageSize, offset: pagination.currentOffset + ) let result = try await session.driver.execute(query: query) self.columns = result.columns self.rows = result.rows self.hasMore = result.rows.count >= pagination.pageSize + // columnDetails (from fetchColumns) provides PK info for edit/delete. + // columns (from query result) only have name/type, no PK metadata. if columnDetails.isEmpty { self.columnDetails = try await session.driver.fetchColumns(table: table.name, schema: nil) } @@ -212,7 +219,10 @@ struct DataBrowserView: View { pagination.currentPage += 1 do { - let query = "SELECT * FROM `\(table.name)` LIMIT \(pagination.pageSize) OFFSET \(pagination.currentOffset)" + let query = SQLBuilder.buildSelect( + table: table.name, type: connection.type, + limit: pagination.pageSize, offset: pagination.currentOffset + ) let result = try await session.driver.execute(query: query) rows.append(contentsOf: result.rows) hasMore = result.rows.count >= pagination.pageSize @@ -235,7 +245,7 @@ struct DataBrowserView: View { return } - let sql = SQLHelper.buildDelete(table: table.name, primaryKeys: pkValues) + let sql = SQLBuilder.buildDelete(table: table.name, type: connection.type, primaryKeys: pkValues) do { _ = try await session.driver.execute(query: sql) diff --git a/TableProMobile/TableProMobile/Views/InsertRowView.swift b/TableProMobile/TableProMobile/Views/InsertRowView.swift index ce9808e3d..a0289a425 100644 --- a/TableProMobile/TableProMobile/Views/InsertRowView.swift +++ b/TableProMobile/TableProMobile/Views/InsertRowView.swift @@ -11,6 +11,7 @@ struct InsertRowView: View { let table: TableInfo let columnDetails: [ColumnInfo] let session: ConnectionSession? + let databaseType: DatabaseType var onInserted: (() -> Void)? @Environment(\.dismiss) private var dismiss @@ -182,8 +183,9 @@ struct InsertRowView: View { } } - let sql = SQLHelper.buildInsert( + let sql = SQLBuilder.buildInsert( table: table.name, + type: databaseType, columns: insertColumns, values: insertValues ) diff --git a/TableProMobile/TableProMobile/Views/RowDetailView.swift b/TableProMobile/TableProMobile/Views/RowDetailView.swift index 308f60114..d7687caba 100644 --- a/TableProMobile/TableProMobile/Views/RowDetailView.swift +++ b/TableProMobile/TableProMobile/Views/RowDetailView.swift @@ -13,6 +13,7 @@ struct RowDetailView: View { let table: TableInfo? let session: ConnectionSession? let columnDetails: [ColumnInfo] + let databaseType: DatabaseType var onSaved: (() -> Void)? @State private var currentIndex: Int @@ -30,6 +31,7 @@ struct RowDetailView: View { table: TableInfo? = nil, session: ConnectionSession? = nil, columnDetails: [ColumnInfo] = [], + databaseType: DatabaseType = .sqlite, onSaved: (() -> Void)? = nil ) { self.columns = columns @@ -37,6 +39,7 @@ struct RowDetailView: View { self.table = table self.session = session self.columnDetails = columnDetails + self.databaseType = databaseType self.onSaved = onSaved _currentIndex = State(initialValue: initialIndex) } @@ -297,8 +300,9 @@ struct RowDetailView: View { return } - let sql = SQLHelper.buildUpdate( + let sql = SQLBuilder.buildUpdate( table: table.name, + type: databaseType, changes: changes, primaryKeys: pkValues ) diff --git a/TableProMobile/TableProMobile/Views/TableListView.swift b/TableProMobile/TableProMobile/Views/TableListView.swift index f8ca69995..9a8e65f15 100644 --- a/TableProMobile/TableProMobile/Views/TableListView.swift +++ b/TableProMobile/TableProMobile/Views/TableListView.swift @@ -45,7 +45,7 @@ struct TableListView: View { TableRow(table: table) } .swipeActions(edge: .leading) { - NavigationLink(value: QuickQuery(table: table)) { + NavigationLink(value: QuickQuery(table: table, databaseType: connection.type)) { Label("Query", systemImage: "terminal") } .tint(.blue) @@ -79,7 +79,9 @@ struct TableListView: View { QueryEditorView( session: session, tables: tables, - initialQuery: "SELECT * FROM \(query.table.name) LIMIT 100" + initialQuery: SQLBuilder.buildSelect( + table: query.table.name, type: query.databaseType, limit: 100, offset: 0 + ) ) } .overlay { @@ -98,6 +100,7 @@ struct TableListView: View { struct QuickQuery: Hashable { let table: TableInfo + let databaseType: DatabaseType } private struct TableRow: View { diff --git a/scripts/ios/build-hiredis-ios.sh b/scripts/ios/build-hiredis-ios.sh index df3889560..4f0170999 100755 --- a/scripts/ios/build-hiredis-ios.sh +++ b/scripts/ios/build-hiredis-ios.sh @@ -101,13 +101,6 @@ build_hiredis_slice() { mkdir -p "$BUILD" cd "$BUILD" - local CMAKE_SYSTEM - if [ "$SDK_NAME" = "iphoneos" ]; then - CMAKE_SYSTEM="iOS" - else - CMAKE_SYSTEM="iOS" - fi - # Create a temporary OpenSSL prefix that cmake can find local OPENSSL_PREFIX="$BUILD_DIR/openssl-prefix-$SDK_NAME-$ARCH" mkdir -p "$OPENSSL_PREFIX/lib" "$OPENSSL_PREFIX/include" diff --git a/scripts/ios/build-libpq-ios.sh b/scripts/ios/build-libpq-ios.sh index 929093182..668b4c05f 100755 --- a/scripts/ios/build-libpq-ios.sh +++ b/scripts/ios/build-libpq-ios.sh @@ -280,17 +280,29 @@ build_slice() { # Compile all source files local ALL_OBJS=() + local FAILED_SRCS=() for src in "${LIBPQ_SRCS[@]}" "${COMMON_SRCS[@]}" "${PORT_SRCS[@]}"; do local obj_name=$(basename "${src%.c}.o") if [ -f "$src" ]; then - $CC $CFLAGS $PG_INCLUDES -DFRONTEND -c "$src" -o "$OBJ_DIR/$obj_name" 2>/dev/null || { - echo " WARNING: skipped $src" - continue - } - ALL_OBJS+=("$OBJ_DIR/$obj_name") + if $CC $CFLAGS $PG_INCLUDES -DFRONTEND -c "$src" -o "$OBJ_DIR/$obj_name" 2>"$OBJ_DIR/${obj_name}.err"; then + ALL_OBJS+=("$OBJ_DIR/$obj_name") + else + FAILED_SRCS+=("$src") + echo " FAILED: $src" + cat "$OBJ_DIR/${obj_name}.err" + fi fi done + if [ ${#FAILED_SRCS[@]} -gt 0 ]; then + echo "" + echo "ERROR: ${#FAILED_SRCS[@]} source files failed to compile:" + printf ' %s\n' "${FAILED_SRCS[@]}" + echo "" + echo "Fix the compilation errors above before creating xcframework." + exit 1 + fi + # strchrnul compat cat > "$OBJ_DIR/strchrnul_compat.c" << 'EOF' #include diff --git a/scripts/ios/build-mariadb-ios.sh b/scripts/ios/build-mariadb-ios.sh index 7272bb9aa..4ece8c2cc 100755 --- a/scripts/ios/build-mariadb-ios.sh +++ b/scripts/ios/build-mariadb-ios.sh @@ -122,8 +122,8 @@ build_mariadb_slice() { -DCLIENT_PLUGIN_PVIO_NPIPE=OFF \ -DCLIENT_PLUGIN_PVIO_SHMEM=OFF - cmake --build . --target mariadb_obj -j"$NCPU" 2>&1 - cmake --build . --target mariadbclient -j"$NCPU" 2>&1 + run_quiet cmake --build . --target mariadb_obj -j"$NCPU" + run_quiet cmake --build . --target mariadbclient -j"$NCPU" # Copy static lib and headers directly (cmake install fails looking for .so plugins) mkdir -p "$INSTALL_DIR/lib" "$INSTALL_DIR/include/mariadb" From 14a665082e0638adf4a76f6bcb5dd472f37c993c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 2 Apr 2026 20:13:02 +0700 Subject: [PATCH 27/61] =?UTF-8?q?fix:=20libpq=20iOS=20build=20=E2=80=94=20?= =?UTF-8?q?fix=20shell=20quoting,=20add=20missing=20pg=5Fconfig=20defines,?= =?UTF-8?q?=20compile=20all=20source=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/ios/build-libpq-ios.sh | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/scripts/ios/build-libpq-ios.sh b/scripts/ios/build-libpq-ios.sh index 668b4c05f..b44e6e8c3 100755 --- a/scripts/ios/build-libpq-ios.sh +++ b/scripts/ios/build-libpq-ios.sh @@ -136,6 +136,16 @@ if kill -0 $CONFIGURE_PID 2>/dev/null; then #define ACCEPT_TYPE_ARG2 struct sockaddr * #define ACCEPT_TYPE_ARG3 socklen_t #define ACCEPT_TYPE_RETURN int +#define MEMSET_LOOP_LIMIT 1024 +#define PG_KRB_SRVNAM "postgres" +#define PG_PRINTF_ATTRIBUTE printf +#define STRERROR_R_INT 1 +#define HAVE_DECL_STRLCAT 1 +#define HAVE_DECL_STRLCPY 1 +#define HAVE_DECL_STRTOINT 0 +#define HAVE_STRONG_RANDOM 1 +#define pg_restrict __restrict +#define HAVE_FUNCNAME__FUNC 1 PGCFG cat > "$NATIVE_DIR/src/include/pg_config_ext.h" << 'PGCFGEXT' @@ -214,8 +224,8 @@ build_slice() { TARGET_FLAG="-target arm64-apple-ios${IOS_DEPLOY_TARGET}" fi - local CFLAGS="-arch $ARCH -isysroot $SDK $TARGET_FLAG -mios-version-min=$IOS_DEPLOY_TARGET -O2" - local PG_INCLUDES="-I$NATIVE_DIR/src/include -I$NATIVE_DIR/src/include/port/darwin -I$NATIVE_DIR/src/interfaces/libpq -I$NATIVE_DIR/src/port -I$OPENSSL_PREFIX/include -I$NATIVE_DIR/src/common" + local -a CFLAGS=(-arch "$ARCH" -isysroot "$SDK" $TARGET_FLAG -mios-version-min="$IOS_DEPLOY_TARGET" -O2 -Wno-int-conversion -Wno-ignored-attributes -Wno-implicit-function-declaration) + local -a PG_INCLUDES=(-I"$NATIVE_DIR/src/include" -I"$NATIVE_DIR/src/include/port/darwin" -I"$NATIVE_DIR/src/interfaces/libpq" -I"$NATIVE_DIR/src/port" -I"$OPENSSL_PREFIX/include" -I"$NATIVE_DIR/src/common") local OBJ_DIR="$BUILD_DIR/obj-$SDK_NAME-$ARCH" mkdir -p "$OBJ_DIR" "$INSTALL_DIR/lib" "$INSTALL_DIR/include" @@ -258,8 +268,6 @@ build_slice() { src/common/wchar.c src/common/encnames.c src/common/fe_memutils.c - src/common/logging.c - src/common/percentrepl.c src/common/psprintf.c ) @@ -270,7 +278,6 @@ build_slice() { src/port/noblock.c src/port/pg_strong_random.c src/port/pgstrsignal.c - src/port/snprintf.c src/port/strerror.c src/port/thread.c src/port/path.c @@ -284,7 +291,7 @@ build_slice() { for src in "${LIBPQ_SRCS[@]}" "${COMMON_SRCS[@]}" "${PORT_SRCS[@]}"; do local obj_name=$(basename "${src%.c}.o") if [ -f "$src" ]; then - if $CC $CFLAGS $PG_INCLUDES -DFRONTEND -c "$src" -o "$OBJ_DIR/$obj_name" 2>"$OBJ_DIR/${obj_name}.err"; then + if "$CC" "${CFLAGS[@]}" "${PG_INCLUDES[@]}" -DFRONTEND -c "$src" -o "$OBJ_DIR/$obj_name" 2>"$OBJ_DIR/${obj_name}.err"; then ALL_OBJS+=("$OBJ_DIR/$obj_name") else FAILED_SRCS+=("$src") @@ -311,7 +318,7 @@ char *strchrnul(const char *s, int c) { return (char *)s; } EOF - $CC $CFLAGS -c "$OBJ_DIR/strchrnul_compat.c" -o "$OBJ_DIR/strchrnul_compat.o" + "$CC" "${CFLAGS[@]}" -c "$OBJ_DIR/strchrnul_compat.c" -o "$OBJ_DIR/strchrnul_compat.o" ALL_OBJS+=("$OBJ_DIR/strchrnul_compat.o") # Create static library From 0f19780d93e16b4189933c2c6e7d91b7ecc7315d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 2 Apr 2026 20:23:42 +0700 Subject: [PATCH 28/61] feat: add MySQL, PostgreSQL, Redis iOS drivers with C bridge modules --- .../TableProMobile/CBridges/CLibPQ/CLibPQ.h | 6 + .../CBridges/CLibPQ/module.modulemap | 4 + .../CBridges/CMariaDB/CMariaDB.h | 6 + .../CBridges/CMariaDB/module.modulemap | 4 + .../TableProMobile/CBridges/CRedis/CRedis.h | 7 + .../CBridges/CRedis/module.modulemap | 4 + .../TableProMobile/Drivers/MySQLDriver.swift | 378 ++++++++++++++ .../Drivers/PostgreSQLDriver.swift | 452 ++++++++++++++++ .../TableProMobile/Drivers/RedisDriver.swift | 489 ++++++++++++++++++ .../Platform/IOSDriverFactory.swift | 26 +- 10 files changed, 1375 insertions(+), 1 deletion(-) create mode 100644 TableProMobile/TableProMobile/CBridges/CLibPQ/CLibPQ.h create mode 100644 TableProMobile/TableProMobile/CBridges/CLibPQ/module.modulemap create mode 100644 TableProMobile/TableProMobile/CBridges/CMariaDB/CMariaDB.h create mode 100644 TableProMobile/TableProMobile/CBridges/CMariaDB/module.modulemap create mode 100644 TableProMobile/TableProMobile/CBridges/CRedis/CRedis.h create mode 100644 TableProMobile/TableProMobile/CBridges/CRedis/module.modulemap create mode 100644 TableProMobile/TableProMobile/Drivers/MySQLDriver.swift create mode 100644 TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift create mode 100644 TableProMobile/TableProMobile/Drivers/RedisDriver.swift diff --git a/TableProMobile/TableProMobile/CBridges/CLibPQ/CLibPQ.h b/TableProMobile/TableProMobile/CBridges/CLibPQ/CLibPQ.h new file mode 100644 index 000000000..68250d184 --- /dev/null +++ b/TableProMobile/TableProMobile/CBridges/CLibPQ/CLibPQ.h @@ -0,0 +1,6 @@ +#ifndef CLibPQ_h +#define CLibPQ_h + +#include + +#endif diff --git a/TableProMobile/TableProMobile/CBridges/CLibPQ/module.modulemap b/TableProMobile/TableProMobile/CBridges/CLibPQ/module.modulemap new file mode 100644 index 000000000..9e35fae38 --- /dev/null +++ b/TableProMobile/TableProMobile/CBridges/CLibPQ/module.modulemap @@ -0,0 +1,4 @@ +module CLibPQ [system] { + header "CLibPQ.h" + export * +} diff --git a/TableProMobile/TableProMobile/CBridges/CMariaDB/CMariaDB.h b/TableProMobile/TableProMobile/CBridges/CMariaDB/CMariaDB.h new file mode 100644 index 000000000..e201da51c --- /dev/null +++ b/TableProMobile/TableProMobile/CBridges/CMariaDB/CMariaDB.h @@ -0,0 +1,6 @@ +#ifndef CMariaDB_h +#define CMariaDB_h + +#include + +#endif diff --git a/TableProMobile/TableProMobile/CBridges/CMariaDB/module.modulemap b/TableProMobile/TableProMobile/CBridges/CMariaDB/module.modulemap new file mode 100644 index 000000000..bbd506d57 --- /dev/null +++ b/TableProMobile/TableProMobile/CBridges/CMariaDB/module.modulemap @@ -0,0 +1,4 @@ +module CMariaDB [system] { + header "CMariaDB.h" + export * +} diff --git a/TableProMobile/TableProMobile/CBridges/CRedis/CRedis.h b/TableProMobile/TableProMobile/CBridges/CRedis/CRedis.h new file mode 100644 index 000000000..5b17632e0 --- /dev/null +++ b/TableProMobile/TableProMobile/CBridges/CRedis/CRedis.h @@ -0,0 +1,7 @@ +#ifndef CRedis_h +#define CRedis_h + +#include +#include + +#endif diff --git a/TableProMobile/TableProMobile/CBridges/CRedis/module.modulemap b/TableProMobile/TableProMobile/CBridges/CRedis/module.modulemap new file mode 100644 index 000000000..1d222eb45 --- /dev/null +++ b/TableProMobile/TableProMobile/CBridges/CRedis/module.modulemap @@ -0,0 +1,4 @@ +module CRedis [system] { + header "CRedis.h" + export * +} diff --git a/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift b/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift new file mode 100644 index 000000000..151b94ef7 --- /dev/null +++ b/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift @@ -0,0 +1,378 @@ +// +// MySQLDriver.swift +// TableProMobile +// +// MySQL driver conforming to DatabaseDriver directly (no plugin layer). +// + +import CMariaDB +import Foundation +import TableProDatabase +import TableProModels + +final class MySQLDriver: DatabaseDriver, @unchecked Sendable { + private let actor = MySQLActor() + private let host: String + private let port: Int + private let user: String + private let password: String + private let database: String + + var supportsSchemas: Bool { false } + var currentSchema: String? { nil } + var supportsTransactions: Bool { true } + private(set) var serverVersion: String? + + init(host: String, port: Int, user: String, password: String, database: String) { + self.host = host + self.port = port + self.user = user + self.password = password + self.database = database + } + + // MARK: - Connection + + func connect() async throws { + try await actor.connect(host: host, port: port, user: user, password: password, database: database) + serverVersion = await actor.serverVersion() + } + + func disconnect() async throws { + await actor.close() + } + + func ping() async throws -> Bool { + try await actor.ping() + } + + // MARK: - Query Execution + + func execute(query: String) async throws -> QueryResult { + let raw = try await actor.execute(query) + return QueryResult( + columns: raw.columns.enumerated().map { i, name in + ColumnInfo( + name: name, + typeName: i < raw.columnTypes.count ? raw.columnTypes[i] : "", + isPrimaryKey: false, + isNullable: true, + defaultValue: nil, + comment: nil, + characterMaxLength: nil, + ordinalPosition: i + ) + }, + rows: raw.rows, + rowsAffected: raw.rowsAffected, + executionTime: raw.executionTime, + isTruncated: raw.isTruncated, + statusMessage: nil + ) + } + + func cancelCurrentQuery() async throws { + // MySQL C API does not support async cancel without a second connection. + // No-op for mobile. + } + + // MARK: - Schema + + func fetchTables(schema: String?) async throws -> [TableInfo] { + let raw = try await actor.execute("SHOW FULL TABLES") + + return raw.rows.compactMap { row in + guard row.count >= 2, let name = row[0], let typeStr = row[1] else { return nil } + let kind: TableInfo.TableKind = typeStr.uppercased() == "VIEW" ? .view : .table + return TableInfo(name: name, type: kind, rowCount: nil, dataSize: nil, comment: nil) + } + } + + func fetchColumns(table: String, schema: String?) async throws -> [ColumnInfo] { + let safe = table.replacingOccurrences(of: "`", with: "``") + let raw = try await actor.execute("SHOW FULL COLUMNS FROM `\(safe)`") + + return raw.rows.enumerated().compactMap { index, row in + guard row.count >= 9, let name = row[0], let dataType = row[1] else { return nil } + let isPK = row[4]?.uppercased().contains("PRI") == true + let isNullable = row[3]?.uppercased() == "YES" + return ColumnInfo( + name: name, + typeName: dataType, + isPrimaryKey: isPK, + isNullable: isNullable, + defaultValue: row[5], + comment: row[8], + characterMaxLength: nil, + ordinalPosition: index + ) + } + } + + func fetchIndexes(table: String, schema: String?) async throws -> [IndexInfo] { + let safe = table.replacingOccurrences(of: "`", with: "``") + let raw = try await actor.execute("SHOW INDEX FROM `\(safe)`") + + var indexMap: [String: (isUnique: Bool, isPrimary: Bool, columns: [String])] = [:] + var order: [String] = [] + + for row in raw.rows { + guard row.count >= 5, let keyName = row[2], let colName = row[4] else { continue } + if indexMap[keyName] == nil { + indexMap[keyName] = ( + isUnique: row[1] == "0", + isPrimary: keyName == "PRIMARY", + columns: [] + ) + order.append(keyName) + } + indexMap[keyName]?.columns.append(colName) + } + + return order.compactMap { name in + guard let entry = indexMap[name] else { return nil } + return IndexInfo( + name: name, + columns: entry.columns, + isUnique: entry.isUnique, + isPrimary: entry.isPrimary, + type: "BTREE" + ) + } + } + + func fetchForeignKeys(table: String, schema: String?) async throws -> [ForeignKeyInfo] { + let safe = table.replacingOccurrences(of: "'", with: "\\'") + let dbSafe = database.replacingOccurrences(of: "'", with: "\\'") + let query = """ + SELECT + kcu.CONSTRAINT_NAME, + kcu.COLUMN_NAME, + kcu.REFERENCED_TABLE_NAME, + kcu.REFERENCED_COLUMN_NAME, + rc.DELETE_RULE, + rc.UPDATE_RULE + FROM information_schema.KEY_COLUMN_USAGE kcu + JOIN information_schema.REFERENTIAL_CONSTRAINTS rc + ON kcu.CONSTRAINT_NAME = rc.CONSTRAINT_NAME + AND kcu.CONSTRAINT_SCHEMA = rc.CONSTRAINT_SCHEMA + WHERE kcu.TABLE_SCHEMA = '\(dbSafe)' + AND kcu.TABLE_NAME = '\(safe)' + AND kcu.REFERENCED_TABLE_NAME IS NOT NULL + ORDER BY kcu.CONSTRAINT_NAME, kcu.ORDINAL_POSITION + """ + let raw = try await actor.execute(query) + + return raw.rows.compactMap { row in + guard row.count >= 6, + let name = row[0], + let column = row[1], + let refTable = row[2], + let refColumn = row[3] else { return nil } + return ForeignKeyInfo( + name: name, + column: column, + referencedTable: refTable, + referencedColumn: refColumn, + onDelete: row[4] ?? "NO ACTION", + onUpdate: row[5] ?? "NO ACTION" + ) + } + } + + func fetchDatabases() async throws -> [String] { + let raw = try await actor.execute("SHOW DATABASES") + return raw.rows.compactMap { $0.first ?? nil } + } + + func switchDatabase(to name: String) async throws { + let safe = name.replacingOccurrences(of: "`", with: "``") + _ = try await actor.execute("USE `\(safe)`") + } + + func switchSchema(to name: String) async throws { + throw MySQLError.unsupported("MySQL does not support schemas") + } + + func beginTransaction() async throws { + _ = try await actor.execute("START TRANSACTION") + } + + func commitTransaction() async throws { + _ = try await actor.execute("COMMIT") + } + + func rollbackTransaction() async throws { + _ = try await actor.execute("ROLLBACK") + } +} + +// MARK: - MySQL Actor (thread-safe C API access) + +private actor MySQLActor { + private var mysql: UnsafeMutablePointer? + + func connect(host: String, port: Int, user: String, password: String, database: String) throws { + guard let handle = mysql_init(nil) else { + throw MySQLError.connectionFailed("Failed to initialize MySQL client") + } + + mysql_options(handle, MYSQL_SET_CHARSET_NAME, "utf8mb4") + + var timeout: UInt32 = 10 + mysql_options(handle, MYSQL_OPT_CONNECT_TIMEOUT, &timeout) + + guard mysql_real_connect( + handle, host, user, password, database, UInt32(port), nil, 0 + ) != nil else { + let msg = String(cString: mysql_error(handle)) + mysql_close(handle) + throw MySQLError.connectionFailed(msg) + } + + self.mysql = handle + } + + func close() { + if let mysql { + mysql_close(mysql) + self.mysql = nil + } + } + + func ping() throws -> Bool { + guard let mysql else { throw MySQLError.notConnected } + if mysql_ping(mysql) != 0 { + throw MySQLError.queryFailed(String(cString: mysql_error(mysql))) + } + return true + } + + func serverVersion() -> String? { + guard let mysql else { return nil } + return String(cString: mysql_get_server_info(mysql)) + } + + func execute(_ query: String) throws -> RawMySQLResult { + guard let mysql else { throw MySQLError.notConnected } + + let start = Date() + + guard mysql_real_query(mysql, query, UInt(query.utf8.count)) == 0 else { + throw MySQLError.queryFailed(String(cString: mysql_error(mysql))) + } + + guard let result = mysql_use_result(mysql) else { + let affected = Int(mysql_affected_rows(mysql)) + return RawMySQLResult( + columns: [], columnTypes: [], rows: [], + rowsAffected: affected, executionTime: Date().timeIntervalSince(start), isTruncated: false + ) + } + defer { mysql_free_result(result) } + + let fieldCount = Int(mysql_num_fields(result)) + var columns: [String] = [] + var columnTypes: [String] = [] + + if let fields = mysql_fetch_fields(result) { + for i in 0..= maxRows { + return RawMySQLResult( + columns: columns, columnTypes: columnTypes, rows: rows, + rowsAffected: 0, executionTime: Date().timeIntervalSince(start), isTruncated: true + ) + } + + let lengths = mysql_fetch_lengths(result) + var rowData: [String?] = [] + for i in 0.. String { + switch typeValue { + case 0: return "DECIMAL" + case 1: return "TINYINT" + case 2: return "SMALLINT" + case 3: return "INT" + case 4: return "FLOAT" + case 5: return "DOUBLE" + case 6: return "NULL" + case 7: return "TIMESTAMP" + case 8: return "BIGINT" + case 9: return "MEDIUMINT" + case 10: return "DATE" + case 11: return "TIME" + case 12: return "DATETIME" + case 13: return "YEAR" + case 15: return "VARCHAR" + case 16: return "BIT" + case 245: return "JSON" + case 246: return "NEWDECIMAL" + case 249: return "TINYTEXT" + case 250: return "MEDIUMTEXT" + case 251: return "LONGTEXT" + case 252: return "TEXT" + case 253: return "VARCHAR" + case 254: return "CHAR" + case 255: return "GEOMETRY" + default: return "UNKNOWN" + } +} + +private struct RawMySQLResult: Sendable { + let columns: [String] + let columnTypes: [String] + let rows: [[String?]] + let rowsAffected: Int + let executionTime: TimeInterval + let isTruncated: Bool +} + +// MARK: - Errors + +enum MySQLError: Error, LocalizedError { + case connectionFailed(String) + case notConnected + case queryFailed(String) + case unsupported(String) + + var errorDescription: String? { + switch self { + case .connectionFailed(let msg): return "MySQL connection failed: \(msg)" + case .notConnected: return "Not connected to MySQL database" + case .queryFailed(let msg): return "MySQL query failed: \(msg)" + case .unsupported(let msg): return msg + } + } +} diff --git a/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift b/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift new file mode 100644 index 000000000..b4bae1ef5 --- /dev/null +++ b/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift @@ -0,0 +1,452 @@ +// +// PostgreSQLDriver.swift +// TableProMobile +// +// PostgreSQL driver conforming to DatabaseDriver directly (no plugin layer). +// + +import CLibPQ +import Foundation +import TableProDatabase +import TableProModels + +final class PostgreSQLDriver: DatabaseDriver, @unchecked Sendable { + private let actor = PostgreSQLActor() + private let host: String + private let port: Int + private let user: String + private let password: String + private let database: String + + var supportsSchemas: Bool { true } + private(set) var currentSchema: String? = "public" + var supportsTransactions: Bool { true } + private(set) var serverVersion: String? + + init(host: String, port: Int, user: String, password: String, database: String) { + self.host = host + self.port = port + self.user = user + self.password = password + self.database = database + } + + // MARK: - Connection + + func connect() async throws { + try await actor.connect(host: host, port: port, user: user, password: password, database: database) + serverVersion = await actor.serverVersion() + } + + func disconnect() async throws { + await actor.close() + } + + func ping() async throws -> Bool { + _ = try await actor.execute("SELECT 1") + return true + } + + // MARK: - Query Execution + + func execute(query: String) async throws -> QueryResult { + let raw = try await actor.execute(query) + return QueryResult( + columns: raw.columns.enumerated().map { i, name in + ColumnInfo( + name: name, + typeName: i < raw.columnTypes.count ? raw.columnTypes[i] : "", + isPrimaryKey: false, + isNullable: true, + defaultValue: nil, + comment: nil, + characterMaxLength: nil, + ordinalPosition: i + ) + }, + rows: raw.rows, + rowsAffected: raw.rowsAffected, + executionTime: raw.executionTime, + isTruncated: raw.isTruncated, + statusMessage: nil + ) + } + + func cancelCurrentQuery() async throws { + await actor.cancel() + } + + // MARK: - Schema + + func fetchTables(schema: String?) async throws -> [TableInfo] { + let schemaName = schema ?? "public" + let safe = schemaName.replacingOccurrences(of: "'", with: "''") + let raw = try await actor.execute(""" + SELECT table_name, table_type + FROM information_schema.tables + WHERE table_schema = '\(safe)' + ORDER BY table_name + """) + + return raw.rows.compactMap { row in + guard row.count >= 2, let name = row[0] else { return nil } + let typeStr = row[1]?.uppercased() ?? "TABLE" + let kind: TableInfo.TableKind + switch typeStr { + case "VIEW": kind = .view + case "SYSTEM TABLE": kind = .systemTable + default: kind = .table + } + return TableInfo(name: name, type: kind, rowCount: nil, dataSize: nil, comment: nil) + } + } + + func fetchColumns(table: String, schema: String?) async throws -> [ColumnInfo] { + let schemaName = schema ?? "public" + let safeTbl = table.replacingOccurrences(of: "'", with: "''") + let safeSchema = schemaName.replacingOccurrences(of: "'", with: "''") + + let raw = try await actor.execute(""" + SELECT + c.column_name, + c.data_type, + c.is_nullable, + c.column_default, + c.character_maximum_length, + CASE WHEN pk.column_name IS NOT NULL THEN 'YES' ELSE 'NO' END AS is_pk + FROM information_schema.columns c + LEFT JOIN ( + SELECT kcu.column_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + WHERE tc.constraint_type = 'PRIMARY KEY' + AND tc.table_schema = '\(safeSchema)' + AND tc.table_name = '\(safeTbl)' + ) pk ON c.column_name = pk.column_name + WHERE c.table_schema = '\(safeSchema)' AND c.table_name = '\(safeTbl)' + ORDER BY c.ordinal_position + """) + + return raw.rows.enumerated().compactMap { index, row in + guard row.count >= 6, let name = row[0], let dataType = row[1] else { return nil } + let maxLen = row[4].flatMap { Int($0) } + return ColumnInfo( + name: name, + typeName: dataType, + isPrimaryKey: row[5] == "YES", + isNullable: row[2]?.uppercased() == "YES", + defaultValue: row[3], + comment: nil, + characterMaxLength: maxLen, + ordinalPosition: index + ) + } + } + + func fetchIndexes(table: String, schema: String?) async throws -> [IndexInfo] { + let schemaName = schema ?? "public" + let safeTbl = table.replacingOccurrences(of: "'", with: "''") + let safeSchema = schemaName.replacingOccurrences(of: "'", with: "''") + + let raw = try await actor.execute(""" + SELECT + i.relname AS index_name, + ix.indisunique, + ix.indisprimary, + a.attname AS column_name + FROM pg_index ix + JOIN pg_class t ON t.oid = ix.indrelid + JOIN pg_class i ON i.oid = ix.indexrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey) + WHERE n.nspname = '\(safeSchema)' AND t.relname = '\(safeTbl)' + ORDER BY i.relname, a.attnum + """) + + var indexMap: [String: (isUnique: Bool, isPrimary: Bool, columns: [String])] = [:] + var order: [String] = [] + + for row in raw.rows { + guard row.count >= 4, let indexName = row[0], let colName = row[3] else { continue } + if indexMap[indexName] == nil { + indexMap[indexName] = ( + isUnique: row[1] == "t", + isPrimary: row[2] == "t", + columns: [] + ) + order.append(indexName) + } + indexMap[indexName]?.columns.append(colName) + } + + return order.compactMap { name in + guard let entry = indexMap[name] else { return nil } + return IndexInfo( + name: name, + columns: entry.columns, + isUnique: entry.isUnique, + isPrimary: entry.isPrimary, + type: "BTREE" + ) + } + } + + func fetchForeignKeys(table: String, schema: String?) async throws -> [ForeignKeyInfo] { + let schemaName = schema ?? "public" + let safeTbl = table.replacingOccurrences(of: "'", with: "''") + let safeSchema = schemaName.replacingOccurrences(of: "'", with: "''") + + let raw = try await actor.execute(""" + SELECT + tc.constraint_name, + kcu.column_name, + ccu.table_name AS referenced_table, + ccu.column_name AS referenced_column, + rc.delete_rule, + rc.update_rule + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + JOIN information_schema.constraint_column_usage ccu + ON tc.constraint_name = ccu.constraint_name + AND tc.table_schema = ccu.table_schema + JOIN information_schema.referential_constraints rc + ON tc.constraint_name = rc.constraint_name + AND tc.table_schema = rc.constraint_schema + WHERE tc.constraint_type = 'FOREIGN KEY' + AND tc.table_schema = '\(safeSchema)' + AND tc.table_name = '\(safeTbl)' + ORDER BY tc.constraint_name + """) + + return raw.rows.compactMap { row in + guard row.count >= 6, + let name = row[0], + let column = row[1], + let refTable = row[2], + let refColumn = row[3] else { return nil } + return ForeignKeyInfo( + name: name, + column: column, + referencedTable: refTable, + referencedColumn: refColumn, + onDelete: row[4] ?? "NO ACTION", + onUpdate: row[5] ?? "NO ACTION" + ) + } + } + + func fetchDatabases() async throws -> [String] { + let raw = try await actor.execute( + "SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname" + ) + return raw.rows.compactMap { $0.first ?? nil } + } + + func switchDatabase(to name: String) async throws { + throw PostgreSQLError.unsupported("PostgreSQL requires a new connection to switch databases") + } + + func switchSchema(to name: String) async throws { + let safe = name.replacingOccurrences(of: "\"", with: "\"\"") + _ = try await actor.execute("SET search_path TO \"\(safe)\"") + currentSchema = name + } + + func beginTransaction() async throws { + _ = try await actor.execute("BEGIN") + } + + func commitTransaction() async throws { + _ = try await actor.execute("COMMIT") + } + + func rollbackTransaction() async throws { + _ = try await actor.execute("ROLLBACK") + } +} + +// MARK: - PostgreSQL Actor (thread-safe C API access) + +private actor PostgreSQLActor { + private var conn: OpaquePointer? + + func connect(host: String, port: Int, user: String, password: String, database: String) throws { + let escapedHost = host.replacingOccurrences(of: "'", with: "\\'") + .replacingOccurrences(of: "\\", with: "\\\\") + let escapedUser = user.replacingOccurrences(of: "'", with: "\\'") + .replacingOccurrences(of: "\\", with: "\\\\") + let escapedPass = password.replacingOccurrences(of: "'", with: "\\'") + .replacingOccurrences(of: "\\", with: "\\\\") + let escapedDb = database.replacingOccurrences(of: "'", with: "\\'") + .replacingOccurrences(of: "\\", with: "\\\\") + + let connStr = "host='\(escapedHost)' port='\(port)' dbname='\(escapedDb)' " + + "user='\(escapedUser)' password='\(escapedPass)' connect_timeout='10' sslmode='disable'" + + let connection = PQconnectdb(connStr) + + guard PQstatus(connection) == CONNECTION_OK else { + let msg = connection.flatMap { String(cString: PQerrorMessage($0)) } ?? "Unknown error" + PQfinish(connection) + throw PostgreSQLError.connectionFailed(msg) + } + + self.conn = connection + } + + func close() { + if let conn { + PQfinish(conn) + self.conn = nil + } + } + + func cancel() { + guard let conn else { return } + let cancel = PQgetCancel(conn) + if let cancel { + var errbuf = [CChar](repeating: 0, count: 256) + PQcancel(cancel, &errbuf, Int32(errbuf.count)) + PQfreeCancel(cancel) + } + } + + func serverVersion() -> String? { + guard let conn else { return nil } + let version = PQserverVersion(conn) + if version == 0 { return nil } + let major = version / 10000 + let minor = (version / 100) % 100 + let patch = version % 100 + return "\(major).\(minor).\(patch)" + } + + func execute(_ query: String) throws -> RawPGResult { + guard let conn else { throw PostgreSQLError.notConnected } + + let start = Date() + let result = PQexec(conn, query) + defer { + if result != nil { PQclear(result) } + } + + let status = PQresultStatus(result) + + if status == PGRES_FATAL_ERROR { + let msg = result.flatMap { String(cString: PQresultErrorMessage($0)) } ?? "Unknown error" + throw PostgreSQLError.queryFailed(msg) + } + + if status == PGRES_COMMAND_OK { + let affectedStr = result.flatMap { String(cString: PQcmdTuples($0)) } ?? "0" + let affected = Int(affectedStr) ?? 0 + return RawPGResult( + columns: [], columnTypes: [], rows: [], + rowsAffected: affected, executionTime: Date().timeIntervalSince(start), isTruncated: false + ) + } + + guard status == PGRES_TUPLES_OK else { + let msg = result.flatMap { String(cString: PQresultErrorMessage($0)) } ?? "Unexpected result status" + throw PostgreSQLError.queryFailed(msg) + } + + let rowCount = Int(PQntuples(result)) + let colCount = Int(PQnfields(result)) + + var columns: [String] = [] + var columnTypes: [String] = [] + + for i in 0.. 100_000 + + for r in 0.. String { + switch oid { + case 16: return "boolean" + case 17: return "bytea" + case 18: return "char" + case 19: return "name" + case 20: return "bigint" + case 21: return "smallint" + case 23: return "integer" + case 25: return "text" + case 26: return "oid" + case 114: return "json" + case 142: return "xml" + case 700: return "real" + case 701: return "double precision" + case 869: return "inet" + case 1042: return "char" + case 1043: return "varchar" + case 1082: return "date" + case 1083: return "time" + case 1114: return "timestamp" + case 1184: return "timestamptz" + case 1700: return "numeric" + case 2950: return "uuid" + case 3802: return "jsonb" + default: return "unknown" + } +} + +private struct RawPGResult: Sendable { + let columns: [String] + let columnTypes: [String] + let rows: [[String?]] + let rowsAffected: Int + let executionTime: TimeInterval + let isTruncated: Bool +} + +// MARK: - Errors + +enum PostgreSQLError: Error, LocalizedError { + case connectionFailed(String) + case notConnected + case queryFailed(String) + case unsupported(String) + + var errorDescription: String? { + switch self { + case .connectionFailed(let msg): return "PostgreSQL connection failed: \(msg)" + case .notConnected: return "Not connected to PostgreSQL database" + case .queryFailed(let msg): return "PostgreSQL query failed: \(msg)" + case .unsupported(let msg): return msg + } + } +} diff --git a/TableProMobile/TableProMobile/Drivers/RedisDriver.swift b/TableProMobile/TableProMobile/Drivers/RedisDriver.swift new file mode 100644 index 000000000..711a090e4 --- /dev/null +++ b/TableProMobile/TableProMobile/Drivers/RedisDriver.swift @@ -0,0 +1,489 @@ +// +// RedisDriver.swift +// TableProMobile +// +// Redis driver conforming to DatabaseDriver directly (no plugin layer). +// Maps Redis key-value concepts to the relational DatabaseDriver protocol. +// + +import CRedis +import Foundation +import TableProDatabase +import TableProModels + +final class RedisDriver: DatabaseDriver, @unchecked Sendable { + private let actor = RedisActor() + private let host: String + private let port: Int + private let password: String? + private let database: Int + + var supportsSchemas: Bool { false } + var currentSchema: String? { nil } + var supportsTransactions: Bool { false } + private(set) var serverVersion: String? + + init(host: String, port: Int, password: String?, database: Int = 0) { + self.host = host + self.port = port + self.password = password + self.database = database + } + + // MARK: - Connection + + func connect() async throws { + try await actor.connect(host: host, port: port, password: password, database: database) + serverVersion = try? await actor.fetchServerVersion() + } + + func disconnect() async throws { + await actor.close() + } + + func ping() async throws -> Bool { + let reply = try await actor.command(["PING"]) + if case .status(let s) = reply, s == "PONG" { return true } + if case .string(let s) = reply, s == "PONG" { return true } + return false + } + + // MARK: - Query Execution + + func execute(query: String) async throws -> QueryResult { + let start = Date() + let args = parseRedisCommand(query) + guard !args.isEmpty else { + throw RedisError.queryFailed("Empty command") + } + + let reply = try await actor.command(args) + let elapsed = Date().timeIntervalSince(start) + return formatReply(reply, executionTime: elapsed) + } + + func cancelCurrentQuery() async throws { + // hiredis does not support async cancel + } + + // MARK: - Schema (Redis key space mapped to tables) + + func fetchTables(schema: String?) async throws -> [TableInfo] { + var keys: [String] = [] + var cursor = "0" + + repeat { + let reply = try await actor.command(["SCAN", cursor, "MATCH", "*", "COUNT", "1000"]) + guard case .array(let parts) = reply, parts.count == 2 else { break } + + if case .string(let nextCursor) = parts[0] { + cursor = nextCursor + } else { + break + } + + if case .array(let keyReplies) = parts[1] { + for kr in keyReplies { + if case .string(let k) = kr { keys.append(k) } + } + } + + if keys.count >= 100_000 { break } + } while cursor != "0" + + return keys.sorted().map { + TableInfo(name: $0, type: .table, rowCount: nil, dataSize: nil, comment: nil) + } + } + + func fetchColumns(table: String, schema: String?) async throws -> [ColumnInfo] { + let reply = try await actor.command(["TYPE", table]) + let typeName: String + if case .status(let s) = reply { + typeName = s + } else if case .string(let s) = reply { + typeName = s + } else { + typeName = "unknown" + } + + return [ + ColumnInfo( + name: "key", + typeName: "string", + isPrimaryKey: true, + isNullable: false, + defaultValue: nil, + comment: nil, + characterMaxLength: nil, + ordinalPosition: 0 + ), + ColumnInfo( + name: "value", + typeName: typeName, + isPrimaryKey: false, + isNullable: true, + defaultValue: nil, + comment: nil, + characterMaxLength: nil, + ordinalPosition: 1 + ) + ] + } + + func fetchIndexes(table: String, schema: String?) async throws -> [IndexInfo] { + [] + } + + func fetchForeignKeys(table: String, schema: String?) async throws -> [ForeignKeyInfo] { + [] + } + + func fetchDatabases() async throws -> [String] { + let reply = try await actor.command(["CONFIG", "GET", "databases"]) + var count = 16 + if case .array(let parts) = reply, parts.count >= 2 { + if case .string(let numStr) = parts[1], let n = Int(numStr) { + count = n + } + } + return (0.. [String] { + var args: [String] = [] + var current = "" + var inQuote: Character? + var escape = false + + for ch in input { + if escape { + current.append(ch) + escape = false + continue + } + if ch == "\\" { + escape = true + continue + } + if let q = inQuote { + if ch == q { + inQuote = nil + } else { + current.append(ch) + } + continue + } + if ch == "\"" || ch == "'" { + inQuote = ch + continue + } + if ch == " " || ch == "\t" { + if !current.isEmpty { + args.append(current) + current = "" + } + continue + } + current.append(ch) + } + if !current.isEmpty { args.append(current) } + return args + } + + private func formatReply(_ reply: RedisReplyValue, executionTime: TimeInterval) -> QueryResult { + switch reply { + case .string(let s): + return QueryResult( + columns: [ColumnInfo(name: "value", typeName: "string", ordinalPosition: 0)], + rows: [[s]], + rowsAffected: 0, + executionTime: executionTime, + statusMessage: nil + ) + case .integer(let i): + return QueryResult( + columns: [ColumnInfo(name: "value", typeName: "integer", ordinalPosition: 0)], + rows: [[String(i)]], + rowsAffected: 0, + executionTime: executionTime, + statusMessage: nil + ) + case .status(let s): + return QueryResult( + columns: [ColumnInfo(name: "status", typeName: "string", ordinalPosition: 0)], + rows: [[s]], + rowsAffected: 0, + executionTime: executionTime, + statusMessage: s + ) + case .error(let msg): + return QueryResult( + columns: [ColumnInfo(name: "error", typeName: "string", ordinalPosition: 0)], + rows: [[msg]], + rowsAffected: 0, + executionTime: executionTime, + statusMessage: nil + ) + case .array(let items): + if isHashResult(items) { + var rows: [[String?]] = [] + for i in stride(from: 0, to: items.count - 1, by: 2) { + let key = items[i].stringRepresentation + let value = items[i + 1].stringRepresentation + rows.append([key, value]) + } + return QueryResult( + columns: [ + ColumnInfo(name: "key", typeName: "string", ordinalPosition: 0), + ColumnInfo(name: "value", typeName: "string", ordinalPosition: 1) + ], + rows: rows, + rowsAffected: 0, + executionTime: executionTime, + isTruncated: rows.count >= 100_000, + statusMessage: nil + ) + } + + let rows: [[String?]] = items.prefix(100_000).enumerated().map { index, item in + [String(index), item.stringRepresentation] + } + return QueryResult( + columns: [ + ColumnInfo(name: "index", typeName: "integer", ordinalPosition: 0), + ColumnInfo(name: "value", typeName: "string", ordinalPosition: 1) + ], + rows: rows, + rowsAffected: 0, + executionTime: executionTime, + isTruncated: items.count > 100_000, + statusMessage: nil + ) + case .null: + return QueryResult( + columns: [ColumnInfo(name: "value", typeName: "string", ordinalPosition: 0)], + rows: [[nil]], + rowsAffected: 0, + executionTime: executionTime, + statusMessage: nil + ) + } + } + + private func isHashResult(_ items: [RedisReplyValue]) -> Bool { + guard items.count >= 2, items.count % 2 == 0 else { return false } + for i in stride(from: 0, to: items.count, by: 2) { + if case .string = items[i] { continue } + return false + } + return true + } +} + +// MARK: - Redis Reply Value + +private enum RedisReplyValue: Sendable { + case string(String) + case integer(Int64) + case array([RedisReplyValue]) + case status(String) + case error(String) + case null + + var stringRepresentation: String? { + switch self { + case .string(let s): return s + case .integer(let i): return String(i) + case .status(let s): return s + case .error(let s): return s + case .null: return nil + case .array(let items): return "[\(items.compactMap(\.stringRepresentation).joined(separator: ", "))]" + } + } +} + +// MARK: - Redis Actor (thread-safe C API access) + +private actor RedisActor { + private var ctx: UnsafeMutablePointer? + + func connect(host: String, port: Int, password: String?, database: Int) throws { + var tv = timeval(tv_sec: 10, tv_usec: 0) + guard let context = redisConnectWithTimeout(host, Int32(port), tv) else { + throw RedisError.connectionFailed("Failed to create Redis context") + } + + if context.pointee.err != 0 { + let msg = withUnsafePointer(to: &context.pointee.errstr.0) { String(cString: $0) } + redisFree(context) + throw RedisError.connectionFailed(msg) + } + + tv = timeval(tv_sec: 30, tv_usec: 0) + redisSetTimeout(context, tv) + + self.ctx = context + + if let password, !password.isEmpty { + let reply = try executeCommand(["AUTH", password]) + if case .error(let msg) = reply { + redisFree(context) + self.ctx = nil + throw RedisError.connectionFailed("Authentication failed: \(msg)") + } + } + + if database != 0 { + let reply = try executeCommand(["SELECT", String(database)]) + if case .error(let msg) = reply { + throw RedisError.connectionFailed("Failed to select database \(database): \(msg)") + } + } + } + + func close() { + if let ctx { + redisFree(ctx) + self.ctx = nil + } + } + + func command(_ args: [String]) throws -> RedisReplyValue { + try executeCommand(args) + } + + func fetchServerVersion() throws -> String? { + let reply = try executeCommand(["INFO", "server"]) + guard case .string(let info) = reply else { return nil } + + for line in info.split(separator: "\n") { + if line.hasPrefix("redis_version:") { + return String(line.dropFirst("redis_version:".count)).trimmingCharacters(in: .whitespacesAndNewlines) + } + } + return nil + } + + private func executeCommand(_ args: [String]) throws -> RedisReplyValue { + guard let ctx else { throw RedisError.notConnected } + + let argc = Int32(args.count) + var cStrings = args.map { strdup($0) } + defer { cStrings.forEach { free($0) } } + + let argv: [UnsafePointer?] = cStrings.map { UnsafePointer($0) } + let argvlen: [Int] = args.map { $0.utf8.count } + + guard let rawReply = redisCommandArgv(ctx, argc, argv, argvlen) else { + if ctx.pointee.err != 0 { + let msg = withUnsafePointer(to: &ctx.pointee.errstr.0) { String(cString: $0) } + throw RedisError.queryFailed(msg) + } + throw RedisError.queryFailed("No reply from server") + } + + let reply = rawReply.assumingMemoryBound(to: redisReply.self) + defer { freeReplyObject(rawReply) } + + return parseReply(reply) + } + + private func parseReply(_ reply: UnsafeMutablePointer) -> RedisReplyValue { + switch reply.pointee.type { + case REDIS_REPLY_STRING: + if let str = reply.pointee.str { + return .string(String(cString: str)) + } + return .null + + case REDIS_REPLY_INTEGER: + return .integer(reply.pointee.integer) + + case REDIS_REPLY_ARRAY: + let count = reply.pointee.elements + guard count > 0, let elements = reply.pointee.element else { + return .array([]) + } + var items: [RedisReplyValue] = [] + for i in 0.. [DatabaseType] { - [.sqlite] + [.sqlite, .mysql, .mariadb, .postgresql, .redshift, .redis] } } From 18981d97ecb3b4123298df105cbf6b78f99e17e4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 2 Apr 2026 20:27:34 +0700 Subject: [PATCH 29/61] fix: pass mutable pointers to redisCommandArgv in RedisDriver --- TableProMobile/TableProMobile/Drivers/RedisDriver.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/TableProMobile/TableProMobile/Drivers/RedisDriver.swift b/TableProMobile/TableProMobile/Drivers/RedisDriver.swift index 711a090e4..61abbb152 100644 --- a/TableProMobile/TableProMobile/Drivers/RedisDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/RedisDriver.swift @@ -406,10 +406,10 @@ private actor RedisActor { var cStrings = args.map { strdup($0) } defer { cStrings.forEach { free($0) } } - let argv: [UnsafePointer?] = cStrings.map { UnsafePointer($0) } - let argvlen: [Int] = args.map { $0.utf8.count } + var argv: [UnsafePointer?] = cStrings.map { UnsafePointer($0) } + var argvlen: [Int] = args.map { $0.utf8.count } - guard let rawReply = redisCommandArgv(ctx, argc, argv, argvlen) else { + guard let rawReply = redisCommandArgv(ctx, argc, &argv, &argvlen) else { if ctx.pointee.err != 0 { let msg = withUnsafePointer(to: &ctx.pointee.errstr.0) { String(cString: $0) } throw RedisError.queryFailed(msg) From 4ba7b6666064308f757089d30717a563aff64038 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 2 Apr 2026 20:36:52 +0700 Subject: [PATCH 30/61] fix: add missing libpq symbols, fix Swift actor isolation and deprecation warnings --- .../TableProMobile/Drivers/MySQLDriver.swift | 7 +++---- .../Drivers/PostgreSQLDriver.swift | 2 +- .../TableProMobile/Drivers/RedisDriver.swift | 2 +- scripts/ios/build-libpq-ios.sh | 17 ++++++++++++++++- 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift b/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift index 151b94ef7..90e247f7a 100644 --- a/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift @@ -299,9 +299,8 @@ private actor MySQLActor { for i in 0.. String { +private nonisolated func mysqlFieldTypeName(_ typeValue: UInt32) -> String { switch typeValue { case 0: return "DECIMAL" case 1: return "TINYINT" diff --git a/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift b/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift index b4bae1ef5..d6101d286 100644 --- a/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift @@ -395,7 +395,7 @@ private actor PostgreSQLActor { // MARK: - PostgreSQL OID Type Names -private func pgOidToTypeName(_ oid: UInt32) -> String { +private nonisolated func pgOidToTypeName(_ oid: UInt32) -> String { switch oid { case 16: return "boolean" case 17: return "bytea" diff --git a/TableProMobile/TableProMobile/Drivers/RedisDriver.swift b/TableProMobile/TableProMobile/Drivers/RedisDriver.swift index 61abbb152..e44666c46 100644 --- a/TableProMobile/TableProMobile/Drivers/RedisDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/RedisDriver.swift @@ -403,7 +403,7 @@ private actor RedisActor { guard let ctx else { throw RedisError.notConnected } let argc = Int32(args.count) - var cStrings = args.map { strdup($0) } + let cStrings = args.map { strdup($0) } defer { cStrings.forEach { free($0) } } var argv: [UnsafePointer?] = cStrings.map { UnsafePointer($0) } diff --git a/scripts/ios/build-libpq-ios.sh b/scripts/ios/build-libpq-ios.sh index b44e6e8c3..c71b419f0 100755 --- a/scripts/ios/build-libpq-ios.sh +++ b/scripts/ios/build-libpq-ios.sh @@ -146,6 +146,8 @@ if kill -0 $CONFIGURE_PID 2>/dev/null; then #define HAVE_STRONG_RANDOM 1 #define pg_restrict __restrict #define HAVE_FUNCNAME__FUNC 1 +#define INT64_MODIFIER "l" +#define HAVE_INT64_TIMESTAMP 1 PGCFG cat > "$NATIVE_DIR/src/include/pg_config_ext.h" << 'PGCFGEXT' @@ -224,7 +226,7 @@ build_slice() { TARGET_FLAG="-target arm64-apple-ios${IOS_DEPLOY_TARGET}" fi - local -a CFLAGS=(-arch "$ARCH" -isysroot "$SDK" $TARGET_FLAG -mios-version-min="$IOS_DEPLOY_TARGET" -O2 -Wno-int-conversion -Wno-ignored-attributes -Wno-implicit-function-declaration) + local -a CFLAGS=(-arch "$ARCH" -isysroot "$SDK" $TARGET_FLAG -mios-version-min="$IOS_DEPLOY_TARGET" -O2 -DHAVE_STRCHRNUL=1 -Wno-int-conversion -Wno-ignored-attributes -Wno-implicit-function-declaration -Wno-error -w) local -a PG_INCLUDES=(-I"$NATIVE_DIR/src/include" -I"$NATIVE_DIR/src/include/port/darwin" -I"$NATIVE_DIR/src/interfaces/libpq" -I"$NATIVE_DIR/src/port" -I"$OPENSSL_PREFIX/include" -I"$NATIVE_DIR/src/common") local OBJ_DIR="$BUILD_DIR/obj-$SDK_NAME-$ARCH" @@ -269,6 +271,14 @@ build_slice() { src/common/encnames.c src/common/fe_memutils.c src/common/psprintf.c + src/common/logging.c + src/common/percentrepl.c + src/common/md5_common.c + src/common/sha1.c + src/common/sha1_int.c + src/common/sha2.c + src/common/sha2_int.c + src/common/pg_prng.c ) # --- Port library source files --- @@ -278,9 +288,14 @@ build_slice() { src/port/noblock.c src/port/pg_strong_random.c src/port/pgstrsignal.c + src/port/snprintf.c src/port/strerror.c src/port/thread.c src/port/path.c + src/port/pg_strong_random.c + src/port/pgstrcasecmp.c + src/port/explicit_bzero.c + src/port/user.c ) cd "$NATIVE_DIR" From 43c5e4d89d193e4127cfb9ff04ab6866b29263e2 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 2 Apr 2026 20:40:27 +0700 Subject: [PATCH 31/61] fix: add md5, pg_bitutils to libpq iOS build for remaining undefined symbols --- scripts/ios/build-libpq-ios.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/ios/build-libpq-ios.sh b/scripts/ios/build-libpq-ios.sh index c71b419f0..6fde17eee 100755 --- a/scripts/ios/build-libpq-ios.sh +++ b/scripts/ios/build-libpq-ios.sh @@ -279,6 +279,9 @@ build_slice() { src/common/sha2.c src/common/sha2_int.c src/common/pg_prng.c + src/common/md5.c + src/common/md5_int.c + src/common/pg_bitutils.c ) # --- Port library source files --- From de260094d21c05c817583ee4550ef100a5521529 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 2 Apr 2026 20:51:28 +0700 Subject: [PATCH 32/61] fix: move pg_bitutils.c to correct src/port path in libpq iOS build --- scripts/ios/build-libpq-ios.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ios/build-libpq-ios.sh b/scripts/ios/build-libpq-ios.sh index 6fde17eee..11c725f8c 100755 --- a/scripts/ios/build-libpq-ios.sh +++ b/scripts/ios/build-libpq-ios.sh @@ -281,7 +281,6 @@ build_slice() { src/common/pg_prng.c src/common/md5.c src/common/md5_int.c - src/common/pg_bitutils.c ) # --- Port library source files --- @@ -299,6 +298,7 @@ build_slice() { src/port/pgstrcasecmp.c src/port/explicit_bzero.c src/port/user.c + src/port/pg_bitutils.c ) cd "$NATIVE_DIR" From a99108e7670cef95d461dd042becfcd8f81d2206 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 2 Apr 2026 20:58:11 +0700 Subject: [PATCH 33/61] =?UTF-8?q?feat:=20iPad=20split=20view=20=E2=80=94?= =?UTF-8?q?=20NavigationSplitView=20with=20sidebar=20+=20detail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Views/ConnectionListView.swift | 180 +++++++++--------- 1 file changed, 92 insertions(+), 88 deletions(-) diff --git a/TableProMobile/TableProMobile/Views/ConnectionListView.swift b/TableProMobile/TableProMobile/Views/ConnectionListView.swift index 0909f687a..a603187de 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionListView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionListView.swift @@ -10,6 +10,7 @@ struct ConnectionListView: View { @Environment(AppState.self) private var appState @State private var showingAddConnection = false @State private var editingConnection: DatabaseConnection? + @State private var selectedConnection: DatabaseConnection? private var groupedConnections: [(String, [DatabaseConnection])] { let grouped = Dictionary(grouping: appState.connections) { $0.type.rawValue.capitalized } @@ -17,97 +18,103 @@ struct ConnectionListView: View { } var body: some View { - NavigationStack { - Group { - if appState.connections.isEmpty { - emptyState - } else { - connectionList - } - } - .navigationTitle("Connections") - .navigationBarTitleDisplayMode(.large) - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { - showingAddConnection = true - } label: { - Image(systemName: "plus") + NavigationSplitView { + sidebar + .navigationTitle("Connections") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + showingAddConnection = true + } label: { + Image(systemName: "plus") + } } } - } - .sheet(isPresented: $showingAddConnection) { - ConnectionFormView { connection in - appState.addConnection(connection) - showingAddConnection = false - } - } - .sheet(item: $editingConnection) { connection in - ConnectionFormView(editing: connection) { updated in - appState.updateConnection(updated) - editingConnection = nil - } - } - .navigationDestination(for: DatabaseConnection.self) { connection in + } detail: { + if let connection = selectedConnection { ConnectedView(connection: connection) + } else { + ContentUnavailableView( + "Select a Connection", + systemImage: "server.rack", + description: Text("Choose a connection from the sidebar.") + ) } } - } - - private var emptyState: some View { - ContentUnavailableView { - Label("No Connections", systemImage: "server.rack") - } description: { - Text("Add a database connection to get started.") - } actions: { - Button("Add Connection") { - showingAddConnection = true + .sheet(isPresented: $showingAddConnection) { + ConnectionFormView { connection in + appState.addConnection(connection) + showingAddConnection = false + } + } + .sheet(item: $editingConnection) { connection in + ConnectionFormView(editing: connection) { updated in + appState.updateConnection(updated) + editingConnection = nil } - .buttonStyle(.borderedProminent) } } - private var connectionList: some View { - List { - ForEach(groupedConnections, id: \.0) { sectionTitle, connections in - Section(sectionTitle) { - ForEach(connections) { connection in - NavigationLink(value: connection) { + @ViewBuilder + private var sidebar: some View { + if appState.connections.isEmpty { + ContentUnavailableView { + Label("No Connections", systemImage: "server.rack") + } description: { + Text("Add a database connection to get started.") + } actions: { + Button("Add Connection") { + showingAddConnection = true + } + .buttonStyle(.borderedProminent) + } + } else { + List(selection: $selectedConnection) { + ForEach(groupedConnections, id: \.0) { sectionTitle, connections in + Section(sectionTitle) { + ForEach(connections) { connection in ConnectionRow(connection: connection) - } - .swipeActions(edge: .trailing, allowsFullSwipe: true) { - Button(role: .destructive) { - appState.removeConnection(connection) - } label: { - Label("Delete", systemImage: "trash") - } - } - .contextMenu { - Button { - editingConnection = connection - } label: { - Label("Edit", systemImage: "pencil") - } - Button { - var duplicate = connection - duplicate.id = UUID() - duplicate.name = "\(connection.name) Copy" - appState.addConnection(duplicate) - } label: { - Label("Duplicate", systemImage: "doc.on.doc") - } - Divider() - Button(role: .destructive) { - appState.removeConnection(connection) - } label: { - Label("Delete", systemImage: "trash") - } + .tag(connection) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + if selectedConnection?.id == connection.id { + selectedConnection = nil + } + appState.removeConnection(connection) + } label: { + Label("Delete", systemImage: "trash") + } + } + .contextMenu { + Button { + editingConnection = connection + } label: { + Label("Edit", systemImage: "pencil") + } + Button { + var duplicate = connection + duplicate.id = UUID() + duplicate.name = "\(connection.name) Copy" + appState.addConnection(duplicate) + } label: { + Label("Duplicate", systemImage: "doc.on.doc") + } + Divider() + Button(role: .destructive) { + if selectedConnection?.id == connection.id { + selectedConnection = nil + } + appState.removeConnection(connection) + } label: { + Label("Delete", systemImage: "trash") + } + } } } } } + .listStyle(.insetGrouped) } - .listStyle(.insetGrouped) } } @@ -128,21 +135,19 @@ private struct ConnectionRow: View { .font(.body) .fontWeight(.medium) - HStack(spacing: 4) { - if connection.type != .sqlite { - Text("\(connection.host):\(connection.port)") - .font(.caption) - .foregroundStyle(.secondary) - } else { - Text(connection.database.components(separatedBy: "/").last ?? "database") - .font(.caption) - .foregroundStyle(.secondary) - } + if connection.type != .sqlite { + Text("\(connection.host):\(connection.port)") + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text(connection.database.components(separatedBy: "/").last ?? "database") + .font(.caption) + .foregroundStyle(.secondary) } } Spacer() -} + } .padding(.vertical, 4) } @@ -172,4 +177,3 @@ private struct ConnectionRow: View { } } } - From 549b1f1098ba5ba1f220bdb8771526f636eeb760 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 2 Apr 2026 21:49:09 +0700 Subject: [PATCH 34/61] =?UTF-8?q?feat:=20add=20SSH=20tunneling=20for=20iOS?= =?UTF-8?q?=20=E2=80=94=20libssh2=20xcframework,=20actor-based=20tunnel,?= =?UTF-8?q?=20SSH=20form=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TableProMobile/TableProMobile/AppState.swift | 4 +- .../CBridges/CLibSSH2/CLibSSH2.h | 31 ++ .../CBridges/CLibSSH2/module.modulemap | 4 + .../TableProMobile/SSH/IOSSSHProvider.swift | 60 +++ .../TableProMobile/SSH/SSHTunnel.swift | 440 ++++++++++++++++++ .../TableProMobile/SSH/SSHTunnelError.swift | 26 ++ .../TableProMobile/SSH/SSHTunnelFactory.swift | 60 +++ .../Views/ConnectionFormView.swift | 110 ++++- scripts/ios/build-libssh2-ios.sh | 163 +++++++ 9 files changed, 896 insertions(+), 2 deletions(-) create mode 100644 TableProMobile/TableProMobile/CBridges/CLibSSH2/CLibSSH2.h create mode 100644 TableProMobile/TableProMobile/CBridges/CLibSSH2/module.modulemap create mode 100644 TableProMobile/TableProMobile/SSH/IOSSSHProvider.swift create mode 100644 TableProMobile/TableProMobile/SSH/SSHTunnel.swift create mode 100644 TableProMobile/TableProMobile/SSH/SSHTunnelError.swift create mode 100644 TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift create mode 100755 scripts/ios/build-libssh2-ios.sh diff --git a/TableProMobile/TableProMobile/AppState.swift b/TableProMobile/TableProMobile/AppState.swift index c6a7fd602..0196d7047 100644 --- a/TableProMobile/TableProMobile/AppState.swift +++ b/TableProMobile/TableProMobile/AppState.swift @@ -18,9 +18,11 @@ final class AppState { init() { let driverFactory = IOSDriverFactory() let secureStore = KeychainSecureStore() + let sshProvider = IOSSSHProvider(secureStore: secureStore) self.connectionManager = ConnectionManager( driverFactory: driverFactory, - secureStore: secureStore + secureStore: secureStore, + sshProvider: sshProvider ) connections = storage.load() } diff --git a/TableProMobile/TableProMobile/CBridges/CLibSSH2/CLibSSH2.h b/TableProMobile/TableProMobile/CBridges/CLibSSH2/CLibSSH2.h new file mode 100644 index 000000000..319fdb2ff --- /dev/null +++ b/TableProMobile/TableProMobile/CBridges/CLibSSH2/CLibSSH2.h @@ -0,0 +1,31 @@ +#ifndef CLibSSH2_h +#define CLibSSH2_h + +#include +#include +#include + +// Wrapper functions for libssh2 macros (Swift cannot call C macros directly) + +static inline LIBSSH2_SESSION *tablepro_libssh2_session_init(void) { + return libssh2_session_init(); +} + +static inline int tablepro_libssh2_session_disconnect(LIBSSH2_SESSION *session, + const char *description) { + return libssh2_session_disconnect(session, description); +} + +static inline ssize_t tablepro_libssh2_channel_read(LIBSSH2_CHANNEL *channel, + char *buf, + size_t buflen) { + return libssh2_channel_read(channel, buf, buflen); +} + +static inline ssize_t tablepro_libssh2_channel_write(LIBSSH2_CHANNEL *channel, + const char *buf, + size_t buflen) { + return libssh2_channel_write(channel, buf, buflen); +} + +#endif diff --git a/TableProMobile/TableProMobile/CBridges/CLibSSH2/module.modulemap b/TableProMobile/TableProMobile/CBridges/CLibSSH2/module.modulemap new file mode 100644 index 000000000..3a64cb241 --- /dev/null +++ b/TableProMobile/TableProMobile/CBridges/CLibSSH2/module.modulemap @@ -0,0 +1,4 @@ +module CLibSSH2 [system] { + header "CLibSSH2.h" + export * +} diff --git a/TableProMobile/TableProMobile/SSH/IOSSSHProvider.swift b/TableProMobile/TableProMobile/SSH/IOSSSHProvider.swift new file mode 100644 index 000000000..d56b97c56 --- /dev/null +++ b/TableProMobile/TableProMobile/SSH/IOSSSHProvider.swift @@ -0,0 +1,60 @@ +// +// IOSSSHProvider.swift +// TableProMobile +// +// SSHProvider implementation for iOS using libssh2. +// + +import Foundation +import TableProDatabase +import TableProModels + +final class IOSSSHProvider: SSHProvider, @unchecked Sendable { + private let lock = NSLock() + private var activeTunnels: [Int: SSHTunnel] = [:] + private let secureStore: SecureStore + + init(secureStore: SecureStore) { + self.secureStore = secureStore + } + + func createTunnel( + config: SSHConfiguration, + remoteHost: String, + remotePort: Int + ) async throws -> TableProDatabase.SSHTunnel { + let sshPassword = try? secureStore.retrieve(forKey: "ssh-\(config.host)-\(config.username)") + let keyPassphrase: String? = if config.privateKeyPath != nil { + try? secureStore.retrieve(forKey: "ssh-key-\(config.host)-\(config.username)") + } else { + nil + } + + let tunnel = try await SSHTunnelFactory.create( + config: config, + remoteHost: remoteHost, + remotePort: remotePort, + sshPassword: sshPassword, + keyPassphrase: keyPassphrase + ) + + let port = await tunnel.port + + lock.lock() + activeTunnels[port] = tunnel + lock.unlock() + + return TableProDatabase.SSHTunnel(localHost: "127.0.0.1", localPort: port) + } + + func closeTunnel(for connectionId: UUID) async throws { + lock.lock() + let allTunnels = activeTunnels + activeTunnels.removeAll() + lock.unlock() + + for (_, tunnel) in allTunnels { + await tunnel.close() + } + } +} diff --git a/TableProMobile/TableProMobile/SSH/SSHTunnel.swift b/TableProMobile/TableProMobile/SSH/SSHTunnel.swift new file mode 100644 index 000000000..0cf35ee88 --- /dev/null +++ b/TableProMobile/TableProMobile/SSH/SSHTunnel.swift @@ -0,0 +1,440 @@ +// +// SSHTunnel.swift +// TableProMobile +// +// Actor-based SSH tunnel using libssh2 C API via CLibSSH2 bridge. +// + +import Foundation +import CLibSSH2 +import os + +actor SSHTunnel { + private static let logger = Logger(subsystem: "com.TablePro.Mobile", category: "SSHTunnel") + + private var session: OpaquePointer? + private var socketFD: Int32 = -1 + private var listenFD: Int32 = -1 + private var localPort: Int = 0 + private var isAlive = true + private var relayTask: Task? + private var keepAliveTask: Task? + + private static let bufferSize = 32_768 + private static let connectionTimeout: Int32 = 10 + + var port: Int { localPort } + + // MARK: - TCP Connection + + func connect(host: String, port: Int) throws { + var hints = addrinfo() + hints.ai_family = AF_UNSPEC + hints.ai_socktype = SOCK_STREAM + hints.ai_protocol = IPPROTO_TCP + + var result: UnsafeMutablePointer? + let portString = String(port) + let rc = getaddrinfo(host, portString, &hints, &result) + + guard rc == 0, let firstAddr = result else { + let errorMsg = rc != 0 ? String(cString: gai_strerror(rc)) : "No address found" + throw SSHTunnelError.connectionFailed("DNS resolution failed for \(host): \(errorMsg)") + } + defer { freeaddrinfo(result) } + + var currentAddr: UnsafeMutablePointer? = firstAddr + var lastError = "No address found" + + while let addrInfo = currentAddr { + let fd = socket(addrInfo.pointee.ai_family, addrInfo.pointee.ai_socktype, addrInfo.pointee.ai_protocol) + guard fd >= 0 else { + currentAddr = addrInfo.pointee.ai_next + continue + } + + let flags = fcntl(fd, F_GETFL, 0) + fcntl(fd, F_SETFL, flags | O_NONBLOCK) + + let connectResult = Darwin.connect(fd, addrInfo.pointee.ai_addr, addrInfo.pointee.ai_addrlen) + + if connectResult != 0 && errno != EINPROGRESS { + Darwin.close(fd) + lastError = "Connection to \(host):\(port) failed" + currentAddr = addrInfo.pointee.ai_next + continue + } + + if connectResult != 0 { + var writePollFD = pollfd(fd: fd, events: Int16(POLLOUT), revents: 0) + let pollResult = poll(&writePollFD, 1, Self.connectionTimeout * 1_000) + + if pollResult <= 0 { + Darwin.close(fd) + lastError = "Connection timed out" + currentAddr = addrInfo.pointee.ai_next + continue + } + + var socketError: Int32 = 0 + var errorLen = socklen_t(MemoryLayout.size) + getsockopt(fd, SOL_SOCKET, SO_ERROR, &socketError, &errorLen) + + if socketError != 0 { + Darwin.close(fd) + lastError = "Connection to \(host):\(port) failed: \(String(cString: strerror(socketError)))" + currentAddr = addrInfo.pointee.ai_next + continue + } + } + + fcntl(fd, F_SETFL, flags) + + socketFD = fd + Self.logger.debug("TCP connected to \(host):\(port)") + return + } + + throw SSHTunnelError.connectionFailed(lastError) + } + + // MARK: - SSH Handshake + + func handshake() throws { + guard socketFD >= 0 else { + throw SSHTunnelError.handshakeFailed("No TCP connection") + } + + guard let sess = tablepro_libssh2_session_init() else { + throw SSHTunnelError.handshakeFailed("Failed to initialize libssh2 session") + } + + libssh2_session_set_blocking(sess, 1) + + let rc = libssh2_session_handshake(sess, socketFD) + if rc != 0 { + libssh2_session_free(sess) + throw SSHTunnelError.handshakeFailed("Handshake failed (error \(rc))") + } + + session = sess + } + + // MARK: - Authentication + + func authenticatePassword(username: String, password: String) throws { + guard let session else { + throw SSHTunnelError.authenticationFailed("No active session") + } + + let rc = libssh2_userauth_password_ex( + session, + username, + UInt32(username.utf8.count), + password, + UInt32(password.utf8.count), + nil + ) + + if rc != 0 { + throw SSHTunnelError.authenticationFailed("Password authentication failed (error \(rc))") + } + + Self.logger.debug("Password authentication successful for \(username)") + } + + func authenticatePublicKey(username: String, keyPath: String, passphrase: String?) throws { + guard let session else { + throw SSHTunnelError.authenticationFailed("No active session") + } + + let expandedPath = (keyPath as NSString).expandingTildeInPath + + guard FileManager.default.fileExists(atPath: expandedPath) else { + throw SSHTunnelError.authenticationFailed("Private key not found at \(keyPath)") + } + + let pubKeyPath = expandedPath + ".pub" + let pubKeyPathOrNil: String? = FileManager.default.fileExists(atPath: pubKeyPath) ? pubKeyPath : nil + + let rc = libssh2_userauth_publickey_fromfile_ex( + session, + username, + UInt32(username.utf8.count), + pubKeyPathOrNil, + expandedPath, + passphrase + ) + + if rc != 0 { + throw SSHTunnelError.authenticationFailed("Public key authentication failed (error \(rc))") + } + + Self.logger.debug("Public key authentication successful for \(username)") + } + + // MARK: - Port Forwarding + + func startForwarding(remoteHost: String, remotePort: Int) throws { + let bound = try bindLocalSocket() + listenFD = bound.fd + localPort = bound.port + + libssh2_session_set_blocking(session, 0) + + Self.logger.info("Forwarding 127.0.0.1:\(self.localPort) -> \(remoteHost):\(remotePort)") + + relayTask = Task.detached { [weak self] in + guard let self else { return } + + while await self.isAlive { + let clientFD = await self.acceptClient() + guard clientFD >= 0 else { continue } + + let channel = await self.openDirectTcpipChannel( + remoteHost: remoteHost, + remotePort: remotePort + ) + + guard let channel else { + Self.logger.error("Failed to open direct-tcpip channel") + Darwin.close(clientFD) + continue + } + + Self.logger.debug("Client connected, relaying to \(remoteHost):\(remotePort)") + + Task.detached { [weak self] in + await self?.relay(clientFD: clientFD, channel: channel) + } + } + + Self.logger.info("Forwarding loop ended") + } + } + + // MARK: - Keep-Alive + + func startKeepAlive() { + guard let session else { return } + + libssh2_keepalive_config(session, 1, 30) + + keepAliveTask = Task.detached { [weak self] in + while !Task.isCancelled { + guard let self else { return } + + let failed = await self.sendKeepAlive() + if failed { + Self.logger.warning("Keep-alive failed, marking tunnel dead") + await self.markDead() + break + } + + try? await Task.sleep(for: .seconds(10)) + } + } + } + + // MARK: - Lifecycle + + func close() { + guard isAlive else { return } + isAlive = false + + relayTask?.cancel() + keepAliveTask?.cancel() + + if listenFD >= 0 { + shutdown(listenFD, SHUT_RDWR) + Darwin.close(listenFD) + listenFD = -1 + } + + if let session { + libssh2_session_set_blocking(session, 1) + tablepro_libssh2_session_disconnect(session, "Closing tunnel") + libssh2_session_free(session) + self.session = nil + } + + if socketFD >= 0 { + Darwin.close(socketFD) + socketFD = -1 + } + + Self.logger.info("Tunnel closed (local port \(self.localPort))") + } + + // MARK: - Private Helpers + + private func markDead() { + isAlive = false + relayTask?.cancel() + keepAliveTask?.cancel() + } + + private func sendKeepAlive() -> Bool { + guard let session else { return true } + var secondsToNext: Int32 = 0 + let rc = libssh2_keepalive_send(session, &secondsToNext) + return rc != 0 + } + + private func bindLocalSocket() throws -> (fd: Int32, port: Int) { + for _ in 0..<20 { + let candidatePort = Int.random(in: 49152...65535) + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { continue } + + var opt: Int32 = 1 + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, socklen_t(MemoryLayout.size)) + + var addr = sockaddr_in() + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = UInt16(candidatePort).bigEndian + addr.sin_addr.s_addr = inet_addr("127.0.0.1") + + let bindResult = withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(fd, $0, socklen_t(MemoryLayout.size)) + } + } + + if bindResult == 0 { + Darwin.listen(fd, 5) + return (fd, candidatePort) + } + + Darwin.close(fd) + } + + throw SSHTunnelError.noAvailablePort + } + + private func acceptClient() -> Int32 { + guard listenFD >= 0 else { return -1 } + + var pollFD = pollfd(fd: listenFD, events: Int16(POLLIN), revents: 0) + let pollResult = poll(&pollFD, 1, 1_000) + + guard pollResult > 0, pollFD.revents & Int16(POLLIN) != 0 else { + return -1 + } + + var clientAddr = sockaddr_in() + var addrLen = socklen_t(MemoryLayout.size) + + return withUnsafeMutablePointer(to: &clientAddr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + accept(listenFD, $0, &addrLen) + } + } + } + + private func openDirectTcpipChannel(remoteHost: String, remotePort: Int) -> OpaquePointer? { + guard let session else { return nil } + + while true { + let channel = libssh2_channel_direct_tcpip_ex( + session, + remoteHost, + Int32(remotePort), + "127.0.0.1", + Int32(localPort) + ) + + if let channel { + return channel + } + + let errNo = libssh2_session_last_errno(session) + guard errNo == LIBSSH2_ERROR_EAGAIN else { + return nil + } + + if !waitForSocket(timeoutMs: 5_000) { + return nil + } + } + } + + private func relay(clientFD: Int32, channel: OpaquePointer) { + let buffer = UnsafeMutablePointer.allocate(capacity: Self.bufferSize) + defer { + buffer.deallocate() + Darwin.close(clientFD) + libssh2_channel_close(channel) + libssh2_channel_free(channel) + } + + while isAlive { + var pollFDs = [ + pollfd(fd: clientFD, events: Int16(POLLIN), revents: 0), + pollfd(fd: socketFD, events: Int16(POLLIN), revents: 0), + ] + + let pollResult = poll(&pollFDs, 2, 500) + if pollResult < 0 { break } + + // Channel -> Client + if pollFDs[1].revents & Int16(POLLIN) != 0 || pollResult == 0 { + let readResult = Int(tablepro_libssh2_channel_read(channel, buffer, Self.bufferSize)) + if readResult > 0 { + var totalSent = 0 + while totalSent < readResult { + let sent = send(clientFD, buffer.advanced(by: totalSent), readResult - totalSent, 0) + if sent <= 0 { return } + totalSent += sent + } + } else if readResult == 0 || libssh2_channel_eof(channel) != 0 { + return + } else if readResult != Int(LIBSSH2_ERROR_EAGAIN) { + return + } + } + + // Client -> Channel + if pollFDs[0].revents & Int16(POLLIN) != 0 { + let clientRead = recv(clientFD, buffer, Self.bufferSize, 0) + if clientRead <= 0 { return } + + var totalWritten = 0 + while totalWritten < Int(clientRead) { + let written = Int(tablepro_libssh2_channel_write( + channel, + buffer.advanced(by: totalWritten), + Int(clientRead) - totalWritten + )) + if written > 0 { + totalWritten += written + } else if written == Int(LIBSSH2_ERROR_EAGAIN) { + if !waitForSocket(timeoutMs: 1_000) { return } + } else { + return + } + } + } + } + } + + private func waitForSocket(timeoutMs: Int32) -> Bool { + guard let session else { return false } + + let directions = libssh2_session_block_directions(session) + + var events: Int16 = 0 + if directions & LIBSSH2_SESSION_BLOCK_INBOUND != 0 { + events |= Int16(POLLIN) + } + if directions & LIBSSH2_SESSION_BLOCK_OUTBOUND != 0 { + events |= Int16(POLLOUT) + } + + guard events != 0 else { return true } + + var pollFD = pollfd(fd: socketFD, events: events, revents: 0) + let rc = poll(&pollFD, 1, timeoutMs) + return rc > 0 + } +} diff --git a/TableProMobile/TableProMobile/SSH/SSHTunnelError.swift b/TableProMobile/TableProMobile/SSH/SSHTunnelError.swift new file mode 100644 index 000000000..4bd835c46 --- /dev/null +++ b/TableProMobile/TableProMobile/SSH/SSHTunnelError.swift @@ -0,0 +1,26 @@ +// +// SSHTunnelError.swift +// TableProMobile +// + +import Foundation + +enum SSHTunnelError: Error, LocalizedError { + case connectionFailed(String) + case handshakeFailed(String) + case authenticationFailed(String) + case noAvailablePort + case channelOpenFailed(String) + case tunnelClosed + + var errorDescription: String? { + switch self { + case .connectionFailed(let msg): return "SSH connection failed: \(msg)" + case .handshakeFailed(let msg): return "SSH handshake failed: \(msg)" + case .authenticationFailed(let msg): return "SSH authentication failed: \(msg)" + case .noAvailablePort: return "No available local port for SSH tunnel" + case .channelOpenFailed(let msg): return "SSH channel open failed: \(msg)" + case .tunnelClosed: return "SSH tunnel is closed" + } + } +} diff --git a/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift b/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift new file mode 100644 index 000000000..52bdd6989 --- /dev/null +++ b/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift @@ -0,0 +1,60 @@ +// +// SSHTunnelFactory.swift +// TableProMobile +// +// Stateless factory that creates fully-connected, authenticated SSH tunnels. +// + +import Foundation +import CLibSSH2 +import TableProModels + +enum SSHTunnelFactory { + private static let initialized: Bool = { + libssh2_init(0) + return true + }() + + static func create( + config: SSHConfiguration, + remoteHost: String, + remotePort: Int, + sshPassword: String?, + keyPassphrase: String? + ) async throws -> SSHTunnel { + _ = initialized + + let tunnel = SSHTunnel() + + try await tunnel.connect(host: config.host, port: config.port) + try await tunnel.handshake() + + switch config.authMethod { + case .password: + guard let password = sshPassword else { + throw SSHTunnelError.authenticationFailed("No SSH password provided") + } + try await tunnel.authenticatePassword(username: config.username, password: password) + + case .publicKey: + guard let keyPath = config.privateKeyPath else { + throw SSHTunnelError.authenticationFailed("No private key path provided") + } + try await tunnel.authenticatePublicKey( + username: config.username, + keyPath: keyPath, + passphrase: keyPassphrase + ) + + default: + throw SSHTunnelError.authenticationFailed( + "Auth method \(config.authMethod.rawValue) not supported on iOS" + ) + } + + try await tunnel.startForwarding(remoteHost: remoteHost, remotePort: remotePort) + await tunnel.startKeepAlive() + + return tunnel + } +} diff --git a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift index 406bf8a2d..90ee1979a 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift @@ -27,6 +27,17 @@ struct ConnectionFormView: View { @State private var showNewDatabaseAlert = false @State private var newDatabaseName = "" + // SSH + @State private var sshEnabled = false + @State private var sshHost = "" + @State private var sshPort = "22" + @State private var sshUsername = "" + @State private var sshPassword = "" + @State private var sshAuthMethod: SSHConfiguration.SSHAuthMethod = .password + @State private var sshKeyPath = "" + @State private var sshKeyPassphrase = "" + @State private var showSSHKeyPicker = false + // Test connection @State private var isTesting = false @State private var testResult: TestResult? @@ -52,6 +63,14 @@ struct ConnectionFormView: View { _username = State(initialValue: connection.username) _database = State(initialValue: connection.database) _sslEnabled = State(initialValue: connection.sslEnabled) + _sshEnabled = State(initialValue: connection.sshEnabled) + if let ssh = connection.sshConfiguration { + _sshHost = State(initialValue: ssh.host) + _sshPort = State(initialValue: String(ssh.port)) + _sshUsername = State(initialValue: ssh.username) + _sshAuthMethod = State(initialValue: ssh.authMethod) + _sshKeyPath = State(initialValue: ssh.privateKeyPath ?? "") + } if connection.type == .sqlite { _selectedFileURL = State(initialValue: URL(fileURLWithPath: connection.database)) } @@ -89,6 +108,10 @@ struct ConnectionFormView: View { } } + if type != .sqlite { + sshSection + } + Section { Button { Task { await testConnection() } @@ -135,6 +158,15 @@ struct ConnectionFormView: View { ) { result in handleFilePickerResult(result) } + .fileImporter( + isPresented: $showSSHKeyPicker, + allowedContentTypes: [.data], + allowsMultipleSelection: false + ) { result in + if case .success(let urls) = result, let url = urls.first { + sshKeyPath = url.path + } + } .alert("New Database", isPresented: $showNewDatabaseAlert) { TextField("Database name", text: $newDatabaseName) Button("Create") { createNewDatabase() } @@ -210,6 +242,53 @@ struct ConnectionFormView: View { } } + // MARK: - SSH Section + + @ViewBuilder + private var sshSection: some View { + Section { + Toggle("SSH Tunnel", isOn: $sshEnabled) + } + + if sshEnabled { + Section("SSH Server") { + TextField("SSH Host", text: $sshHost) + .textInputAutocapitalization(.never) + .keyboardType(.URL) + TextField("SSH Port", text: $sshPort) + .keyboardType(.numberPad) + TextField("SSH Username", text: $sshUsername) + .textInputAutocapitalization(.never) + + Picker("Auth Method", selection: $sshAuthMethod) { + Text("Password").tag(SSHConfiguration.SSHAuthMethod.password) + Text("Private Key").tag(SSHConfiguration.SSHAuthMethod.publicKey) + } + } + + if sshAuthMethod == .password { + Section("SSH Password") { + SecureField("Password", text: $sshPassword) + } + } else { + Section("Private Key") { + Button { + showSSHKeyPicker = true + } label: { + HStack { + Text(sshKeyPath.isEmpty + ? "Select Private Key" + : URL(fileURLWithPath: sshKeyPath).lastPathComponent) + Spacer() + Image(systemName: "folder") + } + } + SecureField("Passphrase (optional)", text: $sshKeyPassphrase) + } + } + } + } + // MARK: - Logic private var canSave: Bool { @@ -299,6 +378,14 @@ struct ConnectionFormView: View { try? appState.connectionManager.storePassword(password, for: tempId) } + let secureStore = KeychainSecureStore() + if sshEnabled && !sshPassword.isEmpty { + try? secureStore.store(sshPassword, forKey: "ssh-\(sshHost)-\(sshUsername)") + } + if sshEnabled && !sshKeyPassphrase.isEmpty { + try? secureStore.store(sshKeyPassphrase, forKey: "ssh-key-\(sshHost)-\(sshUsername)") + } + do { _ = try await appState.connectionManager.connect(testConn) await appState.connectionManager.disconnect(tempId) @@ -312,7 +399,7 @@ struct ConnectionFormView: View { } private func buildConnection() -> DatabaseConnection { - DatabaseConnection( + var conn = DatabaseConnection( id: existingConnection?.id ?? UUID(), name: name.isEmpty ? (selectedFileURL?.lastPathComponent ?? host) : name, type: type, @@ -320,8 +407,19 @@ struct ConnectionFormView: View { port: Int(port) ?? 3306, username: username, database: database, + sshEnabled: sshEnabled, sslEnabled: sslEnabled ) + if sshEnabled { + conn.sshConfiguration = SSHConfiguration( + host: sshHost, + port: Int(sshPort) ?? 22, + username: sshUsername, + authMethod: sshAuthMethod, + privateKeyPath: sshKeyPath.isEmpty ? nil : sshKeyPath + ) + } + return conn } private func save() { @@ -331,6 +429,16 @@ struct ConnectionFormView: View { try? appState.connectionManager.storePassword(password, for: connection.id) } + if sshEnabled { + let secureStore = KeychainSecureStore() + if !sshPassword.isEmpty { + try? secureStore.store(sshPassword, forKey: "ssh-\(sshHost)-\(sshUsername)") + } + if !sshKeyPassphrase.isEmpty { + try? secureStore.store(sshKeyPassphrase, forKey: "ssh-key-\(sshHost)-\(sshUsername)") + } + } + onSave(connection) } } diff --git a/scripts/ios/build-libssh2-ios.sh b/scripts/ios/build-libssh2-ios.sh new file mode 100755 index 000000000..f8d936be7 --- /dev/null +++ b/scripts/ios/build-libssh2-ios.sh @@ -0,0 +1,163 @@ +#!/bin/bash +set -eo pipefail + +# Build static libssh2 for iOS → xcframework +# +# Requires: OpenSSL xcframework already built (run build-openssl-ios.sh first) +# Produces: Libs/ios/LibSSH2.xcframework/ + +LIBSSH2_VERSION="1.11.1" +LIBSSH2_SHA256="d9ec76cbe34db98eec3539fe2c899d26b0c837cb3eb466a56b0f109cabf658f7" +IOS_DEPLOY_TARGET="17.0" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +LIBS_DIR="$PROJECT_DIR/Libs/ios" +BUILD_DIR="$(mktemp -d)" +NCPU=$(sysctl -n hw.ncpu) + +run_quiet() { + local logfile + logfile=$(mktemp) + if ! "$@" > "$logfile" 2>&1; then + echo "FAILED: $*" + tail -50 "$logfile" + rm -f "$logfile" + return 1 + fi + rm -f "$logfile" +} + +cleanup() { + echo " Cleaning up build directory..." + rm -rf "$BUILD_DIR" +} +trap cleanup EXIT + +echo "Building static libssh2 $LIBSSH2_VERSION for iOS" +echo " Build dir: $BUILD_DIR" + +# --- Locate OpenSSL --- + +resolve_openssl() { + local PLATFORM_KEY=$1 + local PREFIX="$BUILD_DIR/openssl-$PLATFORM_KEY" + + local SSL_LIB=$(find "$LIBS_DIR/OpenSSL-SSL.xcframework" -path "*$PLATFORM_KEY*/libssl.a" | head -1) + local CRYPTO_LIB=$(find "$LIBS_DIR/OpenSSL-Crypto.xcframework" -path "*$PLATFORM_KEY*/libcrypto.a" | head -1) + local HEADERS=$(find "$LIBS_DIR/OpenSSL-SSL.xcframework" -path "*$PLATFORM_KEY*/Headers" -type d | head -1) + + if [ -z "$SSL_LIB" ] || [ -z "$CRYPTO_LIB" ]; then + echo "ERROR: OpenSSL not found for $PLATFORM_KEY. Run build-openssl-ios.sh first." + exit 1 + fi + + mkdir -p "$PREFIX/lib" "$PREFIX/include" + cp "$SSL_LIB" "$PREFIX/lib/" + cp "$CRYPTO_LIB" "$PREFIX/lib/" + [ -d "$HEADERS" ] && cp -R "$HEADERS/openssl" "$PREFIX/include/" 2>/dev/null || true + + OPENSSL_PREFIX="$PREFIX" +} + +# --- Download libssh2 --- + +echo "=> Downloading libssh2 $LIBSSH2_VERSION..." +curl -fSL "https://github.com/libssh2/libssh2/releases/download/libssh2-$LIBSSH2_VERSION/libssh2-$LIBSSH2_VERSION.tar.gz" \ + -o "$BUILD_DIR/libssh2.tar.gz" +echo "$LIBSSH2_SHA256 $BUILD_DIR/libssh2.tar.gz" | shasum -a 256 -c - > /dev/null +tar xzf "$BUILD_DIR/libssh2.tar.gz" -C "$BUILD_DIR" +LIBSSH2_SRC="$BUILD_DIR/libssh2-$LIBSSH2_VERSION" +echo " Done." + +# --- Build function --- + +build_libssh2_slice() { + local SDK_NAME=$1 # iphoneos or iphonesimulator + local ARCH=$2 # arm64 + local PLATFORM_KEY=$3 # ios-arm64 or ios-arm64-simulator + local INSTALL_DIR="$BUILD_DIR/install-$SDK_NAME-$ARCH" + + echo "=> Building libssh2 for $SDK_NAME ($ARCH)..." + + resolve_openssl "$PLATFORM_KEY" + + local SDK_PATH + SDK_PATH=$(xcrun --sdk "$SDK_NAME" --show-sdk-path) + + local SRC_COPY="$BUILD_DIR/libssh2-$SDK_NAME-$ARCH" + cp -R "$LIBSSH2_SRC" "$SRC_COPY" + + local BUILD_SUBDIR="$SRC_COPY/cmake-build" + mkdir -p "$BUILD_SUBDIR" + cd "$BUILD_SUBDIR" + + run_quiet cmake .. \ + -DCMAKE_SYSTEM_NAME=iOS \ + -DCMAKE_OSX_DEPLOYMENT_TARGET="$IOS_DEPLOY_TARGET" \ + -DCMAKE_OSX_ARCHITECTURES="$ARCH" \ + -DCMAKE_OSX_SYSROOT="$SDK_PATH" \ + -DCMAKE_INSTALL_PREFIX="$INSTALL_DIR" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \ + -DBUILD_SHARED_LIBS=OFF \ + -DBUILD_EXAMPLES=OFF \ + -DBUILD_TESTING=OFF \ + -DCRYPTO_BACKEND=OpenSSL \ + -DENABLE_ZLIB_COMPRESSION=OFF \ + -DOPENSSL_ROOT_DIR="$OPENSSL_PREFIX" \ + -DOPENSSL_SSL_LIBRARY="$OPENSSL_PREFIX/lib/libssl.a" \ + -DOPENSSL_CRYPTO_LIBRARY="$OPENSSL_PREFIX/lib/libcrypto.a" \ + -DOPENSSL_INCLUDE_DIR="$OPENSSL_PREFIX/include" + + run_quiet cmake --build . --config Release -j"$NCPU" + run_quiet cmake --install . --config Release + + echo " Installed to $INSTALL_DIR" +} + +# --- Build slices --- + +build_libssh2_slice "iphoneos" "arm64" "ios-arm64" +build_libssh2_slice "iphonesimulator" "arm64" "ios-arm64-simulator" + +# --- Create xcframework --- + +DEVICE_DIR="$BUILD_DIR/install-iphoneos-arm64" +SIM_DIR="$BUILD_DIR/install-iphonesimulator-arm64" + +DEVICE_LIB=$(find "$DEVICE_DIR" -name "libssh2.a" | head -1) +SIM_LIB=$(find "$SIM_DIR" -name "libssh2.a" | head -1) +DEVICE_HEADERS=$(find "$DEVICE_DIR" -path "*/include" -type d | head -1) + +if [ -z "$DEVICE_LIB" ] || [ -z "$SIM_LIB" ]; then + echo "ERROR: libssh2.a not found" + find "$DEVICE_DIR" -name "*.a" + find "$SIM_DIR" -name "*.a" + exit 1 +fi + +rm -rf "$LIBS_DIR/LibSSH2.xcframework" + +echo "=> Creating LibSSH2.xcframework..." + +xcodebuild -create-xcframework \ + -library "$DEVICE_LIB" \ + -headers "$DEVICE_HEADERS" \ + -library "$SIM_LIB" \ + -headers "$(find "$SIM_DIR" -path "*/include" -type d | head -1)" \ + -output "$LIBS_DIR/LibSSH2.xcframework" + +echo "" +echo "libssh2 $LIBSSH2_VERSION for iOS built successfully!" +echo " $LIBS_DIR/LibSSH2.xcframework" + +# --- Verify --- + +echo "" +echo "=> Verifying device slice..." +lipo -info "$DEVICE_LIB" +otool -l "$DEVICE_LIB" | grep -A4 "LC_BUILD_VERSION" | head -5 + +echo "" +echo "Done!" From fb73c3a58a7d9e33b624812d7fa02228bd668a54 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 2 Apr 2026 23:00:40 +0700 Subject: [PATCH 35/61] =?UTF-8?q?feat:=20add=20TableProSync=20module=20?= =?UTF-8?q?=E2=80=94=20CloudKit=20sync=20engine,=20record=20mapper,=20meta?= =?UTF-8?q?data=20storage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Packages/TableProCore/Package.swift | 8 +- .../TableProSync/CloudKitSyncEngine.swift | 210 ++++++++++++++++++ .../Sources/TableProSync/SyncConflict.swift | 32 +++ .../Sources/TableProSync/SyncError.swift | 30 +++ .../TableProSync/SyncMetadataStorage.swift | 155 +++++++++++++ .../TableProSync/SyncRecordMapper.swift | 176 +++++++++++++++ .../Sources/TableProSync/SyncRecordType.swift | 6 + 7 files changed, 616 insertions(+), 1 deletion(-) create mode 100644 Packages/TableProCore/Sources/TableProSync/CloudKitSyncEngine.swift create mode 100644 Packages/TableProCore/Sources/TableProSync/SyncConflict.swift create mode 100644 Packages/TableProCore/Sources/TableProSync/SyncError.swift create mode 100644 Packages/TableProCore/Sources/TableProSync/SyncMetadataStorage.swift create mode 100644 Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift create mode 100644 Packages/TableProCore/Sources/TableProSync/SyncRecordType.swift diff --git a/Packages/TableProCore/Package.swift b/Packages/TableProCore/Package.swift index 9cd63b690..28fda7c5d 100644 --- a/Packages/TableProCore/Package.swift +++ b/Packages/TableProCore/Package.swift @@ -12,7 +12,8 @@ let package = Package( .library(name: "TableProPluginKit", targets: ["TableProPluginKit"]), .library(name: "TableProModels", targets: ["TableProModels"]), .library(name: "TableProDatabase", targets: ["TableProDatabase"]), - .library(name: "TableProQuery", targets: ["TableProQuery"]) + .library(name: "TableProQuery", targets: ["TableProQuery"]), + .library(name: "TableProSync", targets: ["TableProSync"]) ], targets: [ .target( @@ -35,6 +36,11 @@ let package = Package( dependencies: ["TableProModels", "TableProPluginKit"], path: "Sources/TableProQuery" ), + .target( + name: "TableProSync", + dependencies: ["TableProModels"], + path: "Sources/TableProSync" + ), .testTarget( name: "TableProModelsTests", dependencies: ["TableProModels", "TableProPluginKit"], diff --git a/Packages/TableProCore/Sources/TableProSync/CloudKitSyncEngine.swift b/Packages/TableProCore/Sources/TableProSync/CloudKitSyncEngine.swift new file mode 100644 index 000000000..2c48c62aa --- /dev/null +++ b/Packages/TableProCore/Sources/TableProSync/CloudKitSyncEngine.swift @@ -0,0 +1,210 @@ +import CloudKit +import Foundation +import os + +public struct PullResult: Sendable { + public let changedRecords: [CKRecord] + public let deletedRecordIDs: [CKRecord.ID] + public let newToken: CKServerChangeToken? + + public init(changedRecords: [CKRecord], deletedRecordIDs: [CKRecord.ID], newToken: CKServerChangeToken?) { + self.changedRecords = changedRecords + self.deletedRecordIDs = deletedRecordIDs + self.newToken = newToken + } +} + +public actor CloudKitSyncEngine { + private static let logger = Logger(subsystem: "com.TablePro", category: "CloudKitSyncEngine") + + private let container: CKContainer + private let database: CKDatabase + private let zoneID: CKRecordZone.ID + + public static let zoneName = "TableProSync" + public static let defaultContainerID = "iCloud.com.TablePro" + + /// CloudKit allows at most 400 items (saves + deletions) per modify operation + private static let maxBatchSize = 400 + private static let maxRetries = 3 + + public init(containerIdentifier: String = defaultContainerID) { + self.container = CKContainer(identifier: containerIdentifier) + self.database = container.privateCloudDatabase + self.zoneID = CKRecordZone.ID(zoneName: Self.zoneName, ownerName: CKCurrentUserDefaultName) + } + + public var currentZoneID: CKRecordZone.ID { zoneID } + + // MARK: - Account Status + + public func accountStatus() async throws -> CKAccountStatus { + try await container.accountStatus() + } + + // MARK: - Zone Management + + public func ensureZoneExists() async throws { + let zone = CKRecordZone(zoneID: zoneID) + _ = try await database.save(zone) + Self.logger.trace("Created or confirmed sync zone: \(Self.zoneName)") + } + + // MARK: - Push + + public func push(records: [CKRecord], deletions: [CKRecord.ID]) async throws { + guard !records.isEmpty || !deletions.isEmpty else { return } + + var remainingSaves = records[...] + var remainingDeletions = deletions[...] + + while !remainingSaves.isEmpty || !remainingDeletions.isEmpty { + let savesCount = min(remainingSaves.count, Self.maxBatchSize) + let batchSaves = Array(remainingSaves.prefix(savesCount)) + remainingSaves = remainingSaves.dropFirst(savesCount) + + let deletionsCount = min(remainingDeletions.count, Self.maxBatchSize - savesCount) + let batchDeletions = Array(remainingDeletions.prefix(deletionsCount)) + remainingDeletions = remainingDeletions.dropFirst(deletionsCount) + + try await pushBatch(records: batchSaves, deletions: batchDeletions) + } + + Self.logger.info("Pushed \(records.count) records, \(deletions.count) deletions") + } + + private func pushBatch(records: [CKRecord], deletions: [CKRecord.ID]) async throws { + try await withRetry { + let operation = CKModifyRecordsOperation( + recordsToSave: records, + recordIDsToDelete: deletions + ) + // .changedKeys overwrites only the fields we set, safe for partial updates + operation.savePolicy = .changedKeys + operation.isAtomic = false + + return try await withCheckedThrowingContinuation { continuation in + operation.perRecordSaveBlock = { recordID, result in + if case .failure(let error) = result { + Self.logger.error( + "Failed to save record \(recordID.recordName): \(error.localizedDescription)" + ) + } + } + + operation.modifyRecordsResultBlock = { result in + switch result { + case .success: + continuation.resume() + case .failure(let error): + continuation.resume(throwing: error) + } + } + + self.database.add(operation) + } + } + } + + // MARK: - Pull + + public func pull(since token: CKServerChangeToken?) async throws -> PullResult { + try await withRetry { + try await performPull(since: token) + } + } + + private func performPull(since token: CKServerChangeToken?) async throws -> PullResult { + let configuration = CKFetchRecordZoneChangesOperation.ZoneConfiguration() + configuration.previousServerChangeToken = token + + let operation = CKFetchRecordZoneChangesOperation( + recordZoneIDs: [zoneID], + configurationsByRecordZoneID: [zoneID: configuration] + ) + + var changedRecords: [CKRecord] = [] + var deletedRecordIDs: [CKRecord.ID] = [] + var newToken: CKServerChangeToken? + + return try await withCheckedThrowingContinuation { continuation in + operation.recordWasChangedBlock = { _, result in + if case .success(let record) = result { + changedRecords.append(record) + } + } + + operation.recordWithIDWasDeletedBlock = { recordID, _ in + deletedRecordIDs.append(recordID) + } + + operation.recordZoneChangeTokensUpdatedBlock = { _, serverToken, _ in + newToken = serverToken + } + + operation.recordZoneFetchResultBlock = { _, result in + switch result { + case .success(let (serverToken, _, _)): + newToken = serverToken + case .failure(let error): + Self.logger.warning("Zone fetch result error: \(error.localizedDescription)") + } + } + + operation.fetchRecordZoneChangesResultBlock = { result in + switch result { + case .success: + continuation.resume(returning: PullResult( + changedRecords: changedRecords, + deletedRecordIDs: deletedRecordIDs, + newToken: newToken + )) + case .failure(let error): + continuation.resume(throwing: error) + } + } + + database.add(operation) + } + } + + // MARK: - Retry Logic + + private func withRetry(_ operation: () async throws -> T) async throws -> T { + var lastError: Error? + + for attempt in 0.. Bool { + switch error.code { + case .networkUnavailable, .networkFailure, .serviceUnavailable, + .requestRateLimited, .zoneBusy: + return true + default: + return false + } + } + + private func retryDelay(for error: CKError, attempt: Int) -> Double { + if let suggestedDelay = error.retryAfterSeconds { + return suggestedDelay + } + return Double(1 << attempt) + } +} diff --git a/Packages/TableProCore/Sources/TableProSync/SyncConflict.swift b/Packages/TableProCore/Sources/TableProSync/SyncConflict.swift new file mode 100644 index 000000000..6d37b7432 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProSync/SyncConflict.swift @@ -0,0 +1,32 @@ +import CloudKit +import Foundation + +public struct SyncConflict: Identifiable, Sendable { + public let id: UUID + public let recordType: SyncRecordType + public let entityName: String + public let localModifiedAt: Date + public let serverModifiedAt: Date + public let serverRecord: CKRecord + + public init( + recordType: SyncRecordType, + entityName: String, + localModifiedAt: Date, + serverModifiedAt: Date, + serverRecord: CKRecord + ) { + self.id = UUID() + self.recordType = recordType + self.entityName = entityName + self.localModifiedAt = localModifiedAt + self.serverModifiedAt = serverModifiedAt + self.serverRecord = serverRecord + } +} + +public enum SyncStatus: Sendable { + case idle + case syncing + case error(String) +} diff --git a/Packages/TableProCore/Sources/TableProSync/SyncError.swift b/Packages/TableProCore/Sources/TableProSync/SyncError.swift new file mode 100644 index 000000000..71fbdc09a --- /dev/null +++ b/Packages/TableProCore/Sources/TableProSync/SyncError.swift @@ -0,0 +1,30 @@ +import Foundation + +public enum SyncError: Error, LocalizedError, Sendable { + case noAccount + case networkUnavailable + case zoneCreationFailed(String) + case pushFailed(String) + case pullFailed(String) + case tokenExpired + case unknownError(String) + + public var errorDescription: String? { + switch self { + case .noAccount: + return String(localized: "No iCloud account available") + case .networkUnavailable: + return String(localized: "Network is unavailable") + case .zoneCreationFailed(let detail): + return String(format: String(localized: "Failed to create sync zone: %@"), detail) + case .pushFailed(let detail): + return String(format: String(localized: "Failed to push changes: %@"), detail) + case .pullFailed(let detail): + return String(format: String(localized: "Failed to pull changes: %@"), detail) + case .tokenExpired: + return String(localized: "Sync token expired, full sync required") + case .unknownError(let detail): + return String(format: String(localized: "Sync error: %@"), detail) + } + } +} diff --git a/Packages/TableProCore/Sources/TableProSync/SyncMetadataStorage.swift b/Packages/TableProCore/Sources/TableProSync/SyncMetadataStorage.swift new file mode 100644 index 000000000..ae68be863 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProSync/SyncMetadataStorage.swift @@ -0,0 +1,155 @@ +import CloudKit +import Foundation +import os + +public struct Tombstone: Codable, Sendable { + public let id: String + public let deletedAt: Date + + public init(id: String, deletedAt: Date = Date()) { + self.id = id + self.deletedAt = deletedAt + } +} + +public final class SyncMetadataStorage: Sendable { + private static let logger = Logger(subsystem: "com.TablePro", category: "SyncMetadataStorage") + + private let defaults: UserDefaults + private let prefix: String + + public init(defaults: UserDefaults = .standard, prefix: String = "com.TablePro.sync") { + self.defaults = defaults + self.prefix = prefix + } + + // MARK: - Server Change Token + + public func loadToken() -> CKServerChangeToken? { + guard let data = defaults.data(forKey: key("serverChangeToken")) else { return nil } + do { + return try NSKeyedUnarchiver.unarchivedObject(ofClass: CKServerChangeToken.self, from: data) + } catch { + Self.logger.error("Failed to unarchive sync token: \(error.localizedDescription)") + return nil + } + } + + public func saveToken(_ token: CKServerChangeToken?) { + guard let token else { + defaults.removeObject(forKey: key("serverChangeToken")) + return + } + do { + let data = try NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true) + defaults.set(data, forKey: key("serverChangeToken")) + } catch { + Self.logger.error("Failed to archive sync token: \(error.localizedDescription)") + } + } + + // MARK: - Dirty Tracking + + public func dirtyIDs(for type: SyncRecordType) -> Set { + Set(defaults.stringArray(forKey: key("dirty.\(type.rawValue)")) ?? []) + } + + public func markDirty(_ id: String, type: SyncRecordType) { + var ids = dirtyIDs(for: type) + ids.insert(id) + defaults.set(Array(ids), forKey: key("dirty.\(type.rawValue)")) + } + + public func removeDirty(_ id: String, type: SyncRecordType) { + var ids = dirtyIDs(for: type) + ids.remove(id) + if ids.isEmpty { + defaults.removeObject(forKey: key("dirty.\(type.rawValue)")) + } else { + defaults.set(Array(ids), forKey: key("dirty.\(type.rawValue)")) + } + } + + public func clearDirty(type: SyncRecordType) { + defaults.removeObject(forKey: key("dirty.\(type.rawValue)")) + } + + // MARK: - Tombstones + + public func tombstones(for type: SyncRecordType) -> [Tombstone] { + guard let data = defaults.data(forKey: key("tombstones.\(type.rawValue)")) else { return [] } + do { + return try JSONDecoder().decode([Tombstone].self, from: data) + } catch { + Self.logger.error("Failed to decode tombstones for \(type.rawValue): \(error.localizedDescription)") + return [] + } + } + + public func addTombstone(_ id: String, type: SyncRecordType) { + var current = tombstones(for: type) + current.append(Tombstone(id: id)) + saveTombstones(current, for: type) + } + + public func removeTombstone(_ id: String, type: SyncRecordType) { + var current = tombstones(for: type) + current.removeAll { $0.id == id } + saveTombstones(current, for: type) + } + + public func clearTombstones(type: SyncRecordType) { + defaults.removeObject(forKey: key("tombstones.\(type.rawValue)")) + } + + public func pruneTombstones(olderThan days: Int) { + let cutoff = Calendar.current.date(byAdding: .day, value: -days, to: Date()) ?? Date() + for type in SyncRecordType.allCases { + var current = tombstones(for: type) + let before = current.count + current.removeAll { $0.deletedAt < cutoff } + if current.count != before { + saveTombstones(current, for: type) + } + } + } + + // MARK: - Last Sync Date + + public var lastSyncDate: Date? { + get { defaults.object(forKey: key("lastSyncDate")) as? Date } + set { defaults.set(newValue, forKey: key("lastSyncDate")) } + } + + // MARK: - Reset + + public func clearAll() { + saveToken(nil) + for type in SyncRecordType.allCases { + clearDirty(type: type) + clearTombstones(type: type) + } + defaults.removeObject(forKey: key("lastSyncDate")) + Self.logger.trace("Cleared all sync metadata") + } + + // MARK: - Helpers + + private func key(_ suffix: String) -> String { + "\(prefix).\(suffix)" + } + + private func saveTombstones(_ tombstones: [Tombstone], for type: SyncRecordType) { + let storageKey = key("tombstones.\(type.rawValue)") + if tombstones.isEmpty { + defaults.removeObject(forKey: storageKey) + return + } + do { + let data = try JSONEncoder().encode(tombstones) + defaults.set(data, forKey: storageKey) + } catch { + Self.logger.error("Failed to encode tombstones for \(type.rawValue): \(error.localizedDescription)") + } + } +} diff --git a/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift b/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift new file mode 100644 index 000000000..0b3eae3ac --- /dev/null +++ b/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift @@ -0,0 +1,176 @@ +import CloudKit +import Foundation +import os + +import TableProModels + +public enum SyncRecordMapper { + private static let logger = Logger(subsystem: "com.TablePro", category: "SyncRecordMapper") + private static let encoder = JSONEncoder() + private static let decoder = JSONDecoder() + + private static let schemaVersion: Int64 = 1 + + // MARK: - Record Name Helpers + + public static func recordID(type: SyncRecordType, id: String, in zone: CKRecordZone.ID) -> CKRecord.ID { + let recordName: String + switch type { + case .connection: recordName = "Connection_\(id)" + case .group: recordName = "Group_\(id)" + } + return CKRecord.ID(recordName: recordName, zoneID: zone) + } + + // MARK: - Connection -> CKRecord + + public static func toRecord(_ connection: DatabaseConnection, zoneID: CKRecordZone.ID) -> CKRecord { + let id = recordID(type: .connection, id: connection.id.uuidString, in: zoneID) + let record = CKRecord(recordType: SyncRecordType.connection.rawValue, recordID: id) + + record["connectionId"] = connection.id.uuidString as CKRecordValue + record["name"] = connection.name as CKRecordValue + record["host"] = connection.host as CKRecordValue + record["port"] = Int64(connection.port) as CKRecordValue + record["database"] = connection.database as CKRecordValue + record["username"] = connection.username as CKRecordValue + record["type"] = connection.type.rawValue as CKRecordValue + record["sortOrder"] = Int64(connection.sortOrder) as CKRecordValue + record["isReadOnly"] = Int64(connection.isReadOnly ? 1 : 0) as CKRecordValue + record["sshEnabled"] = Int64(connection.sshEnabled ? 1 : 0) as CKRecordValue + record["sslEnabled"] = Int64(connection.sslEnabled ? 1 : 0) as CKRecordValue + + if let colorTag = connection.colorTag { + record["colorTag"] = colorTag as CKRecordValue + } + if let groupId = connection.groupId { + record["groupId"] = groupId.uuidString as CKRecordValue + } + if let queryTimeout = connection.queryTimeoutSeconds { + record["queryTimeoutSeconds"] = Int64(queryTimeout) as CKRecordValue + } + + if let sshConfig = connection.sshConfiguration { + do { + let data = try encoder.encode(sshConfig) + record["sshConfigJson"] = data as CKRecordValue + } catch { + logger.warning("Failed to encode SSH config for sync: \(error.localizedDescription)") + } + } + + if let sslConfig = connection.sslConfiguration { + do { + let data = try encoder.encode(sslConfig) + record["sslConfigJson"] = data as CKRecordValue + } catch { + logger.warning("Failed to encode SSL config for sync: \(error.localizedDescription)") + } + } + + if !connection.additionalFields.isEmpty { + do { + let data = try encoder.encode(connection.additionalFields) + record["additionalFieldsJson"] = data as CKRecordValue + } catch { + logger.warning("Failed to encode additional fields for sync: \(error.localizedDescription)") + } + } + + record["modifiedAtLocal"] = Date() as CKRecordValue + record["schemaVersion"] = schemaVersion as CKRecordValue + + return record + } + + // MARK: - CKRecord -> Connection + + public static func toConnection(_ record: CKRecord) -> DatabaseConnection? { + guard let idString = record["connectionId"] as? String, + let id = UUID(uuidString: idString), + let name = record["name"] as? String, + let typeRaw = record["type"] as? String + else { + logger.warning("Failed to decode connection from CKRecord: missing required fields") + return nil + } + + let host = record["host"] as? String ?? "127.0.0.1" + let port = (record["port"] as? Int64).map { Int($0) } ?? 3306 + let database = record["database"] as? String ?? "" + let username = record["username"] as? String ?? "" + let colorTag = record["colorTag"] as? String + let groupId = (record["groupId"] as? String).flatMap { UUID(uuidString: $0) } + let sortOrder = (record["sortOrder"] as? Int64).map { Int($0) } ?? 0 + let isReadOnly = (record["isReadOnly"] as? Int64 ?? 0) != 0 + let queryTimeout = (record["queryTimeoutSeconds"] as? Int64).map { Int($0) } + let sshEnabled = (record["sshEnabled"] as? Int64 ?? 0) != 0 + let sslEnabled = (record["sslEnabled"] as? Int64 ?? 0) != 0 + + var sshConfig: SSHConfiguration? + if let sshData = record["sshConfigJson"] as? Data { + sshConfig = try? decoder.decode(SSHConfiguration.self, from: sshData) + } + + var sslConfig: SSLConfiguration? + if let sslData = record["sslConfigJson"] as? Data { + sslConfig = try? decoder.decode(SSLConfiguration.self, from: sslData) + } + + var additionalFields: [String: String] = [:] + if let fieldsData = record["additionalFieldsJson"] as? Data { + additionalFields = (try? decoder.decode([String: String].self, from: fieldsData)) ?? [:] + } + + return DatabaseConnection( + id: id, + name: name, + type: DatabaseType(rawValue: typeRaw), + host: host, + port: port, + username: username, + database: database, + colorTag: colorTag, + isReadOnly: isReadOnly, + queryTimeoutSeconds: queryTimeout, + additionalFields: additionalFields, + sshEnabled: sshEnabled, + sshConfiguration: sshConfig, + sslEnabled: sslEnabled, + sslConfiguration: sslConfig, + groupId: groupId, + sortOrder: sortOrder + ) + } + + // MARK: - Group -> CKRecord + + public static func toRecord(_ group: ConnectionGroup, zoneID: CKRecordZone.ID) -> CKRecord { + let id = recordID(type: .group, id: group.id.uuidString, in: zoneID) + let record = CKRecord(recordType: SyncRecordType.group.rawValue, recordID: id) + + record["groupId"] = group.id.uuidString as CKRecordValue + record["name"] = group.name as CKRecordValue + record["sortOrder"] = Int64(group.sortOrder) as CKRecordValue + record["modifiedAtLocal"] = Date() as CKRecordValue + record["schemaVersion"] = schemaVersion as CKRecordValue + + return record + } + + // MARK: - CKRecord -> Group + + public static func toGroup(_ record: CKRecord) -> ConnectionGroup? { + guard let idStr = record["groupId"] as? String, + let id = UUID(uuidString: idStr), + let name = record["name"] as? String + else { + logger.warning("Failed to decode group from CKRecord: missing required fields") + return nil + } + + let sortOrder = (record["sortOrder"] as? Int64).map { Int($0) } ?? 0 + + return ConnectionGroup(id: id, name: name, sortOrder: sortOrder) + } +} diff --git a/Packages/TableProCore/Sources/TableProSync/SyncRecordType.swift b/Packages/TableProCore/Sources/TableProSync/SyncRecordType.swift new file mode 100644 index 000000000..52e22f497 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProSync/SyncRecordType.swift @@ -0,0 +1,6 @@ +import Foundation + +public enum SyncRecordType: String, CaseIterable, Sendable { + case connection = "Connection" + case group = "ConnectionGroup" +} From 950b8809fbcb5bd2dfdc8362c6b399a06a3c7417 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 3 Apr 2026 12:26:17 +0700 Subject: [PATCH 36/61] =?UTF-8?q?feat:=20integrate=20iCloud=20sync=20?= =?UTF-8?q?=E2=80=94=20IOSSyncCoordinator,=20AppState=20sync=20on=20change?= =?UTF-8?q?/active,=20debounced=20push?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TableProMobile/TableProMobile/AppState.swift | 15 ++ .../Sync/IOSSyncCoordinator.swift | 149 ++++++++++++++++++ .../TableProMobile/TableProMobileApp.swift | 11 +- 3 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift diff --git a/TableProMobile/TableProMobile/AppState.swift b/TableProMobile/TableProMobile/AppState.swift index 0196d7047..c01f25b7e 100644 --- a/TableProMobile/TableProMobile/AppState.swift +++ b/TableProMobile/TableProMobile/AppState.swift @@ -12,6 +12,7 @@ import TableProModels final class AppState { var connections: [DatabaseConnection] = [] let connectionManager: ConnectionManager + let syncCoordinator = IOSSyncCoordinator() private let storage = ConnectionPersistence() @@ -25,17 +26,29 @@ final class AppState { sshProvider: sshProvider ) connections = storage.load() + + syncCoordinator.onConnectionsChanged = { [weak self] merged in + guard let self else { return } + self.connections = merged + self.storage.save(merged) + } + + Task { await syncCoordinator.sync(localConnections: connections) } } func addConnection(_ connection: DatabaseConnection) { connections.append(connection) storage.save(connections) + syncCoordinator.markDirty(connection.id) + syncCoordinator.scheduleSyncAfterChange(localConnections: connections) } func updateConnection(_ connection: DatabaseConnection) { if let index = connections.firstIndex(where: { $0.id == connection.id }) { connections[index] = connection storage.save(connections) + syncCoordinator.markDirty(connection.id) + syncCoordinator.scheduleSyncAfterChange(localConnections: connections) } } @@ -43,6 +56,8 @@ final class AppState { connections.removeAll { $0.id == connection.id } try? connectionManager.deletePassword(for: connection.id) storage.save(connections) + syncCoordinator.markDeleted(connection.id) + syncCoordinator.scheduleSyncAfterChange(localConnections: connections) } } diff --git a/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift b/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift new file mode 100644 index 000000000..49b5014f1 --- /dev/null +++ b/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift @@ -0,0 +1,149 @@ +// +// IOSSyncCoordinator.swift +// TableProMobile +// + +import CloudKit +import Foundation +import Observation +import TableProModels +import TableProSync + +@MainActor @Observable +final class IOSSyncCoordinator { + var status: SyncStatus = .idle + var lastSyncDate: Date? + + private let engine = CloudKitSyncEngine() + private let metadata = SyncMetadataStorage() + private var debounceTask: Task? + + // Callback to update AppState connections + var onConnectionsChanged: (([DatabaseConnection]) -> Void)? + + // MARK: - Sync + + func sync(localConnections: [DatabaseConnection]) async { + guard status != .syncing else { return } + status = .syncing + + do { + let accountStatus = try await engine.accountStatus() + guard accountStatus == .available else { + status = .error("iCloud account not available") + return + } + + try await engine.ensureZoneExists() + try await push(localConnections: localConnections) + let remoteConnections = try await pull() + let merged = merge(local: localConnections, remote: remoteConnections) + onConnectionsChanged?(merged) + + metadata.lastSyncDate = Date() + lastSyncDate = metadata.lastSyncDate + status = .idle + } catch let error as SyncError where error == .tokenExpired { + metadata.saveToken(nil) + status = .idle + await sync(localConnections: localConnections) + } catch { + status = .error(error.localizedDescription) + } + } + + func markDirty(_ connectionId: UUID) { + metadata.markDirty(connectionId.uuidString, type: .connection) + } + + func markDeleted(_ connectionId: UUID) { + metadata.addTombstone(connectionId.uuidString, type: .connection) + } + + func scheduleSyncAfterChange(localConnections: [DatabaseConnection]) { + debounceTask?.cancel() + debounceTask = Task { + try? await Task.sleep(nanoseconds: 2_000_000_000) + guard !Task.isCancelled else { return } + await sync(localConnections: localConnections) + } + } + + // MARK: - Push + + private func push(localConnections: [DatabaseConnection]) async throws { + let zoneID = await engine.currentZoneID + + // Dirty connections + let dirtyIDs = metadata.dirtyIDs(for: .connection) + let dirtyRecords = localConnections + .filter { dirtyIDs.contains($0.id.uuidString) } + .map { SyncRecordMapper.toRecord($0, zoneID: zoneID) } + + // Tombstones + let tombstones = metadata.tombstones(for: .connection) + let deletions = tombstones.map { + CKRecord.ID(recordName: "Connection_\($0.id)", zoneID: zoneID) + } + + guard !dirtyRecords.isEmpty || !deletions.isEmpty else { return } + + try await engine.push(records: dirtyRecords, deletions: deletions) + metadata.clearDirty(type: .connection) + metadata.clearTombstones(type: .connection) + } + + // MARK: - Pull + + private func pull() async throws -> [DatabaseConnection] { + let token = metadata.loadToken() + let result = try await engine.pull(since: token) + + if let newToken = result.newToken { + metadata.saveToken(newToken) + } + + var connections: [DatabaseConnection] = [] + + for record in result.changedRecords { + if record.recordType == SyncRecordType.connection.rawValue { + if let connection = SyncRecordMapper.toConnection(record) { + connections.append(connection) + } + } + } + + return connections + } + + // MARK: - Merge (last-write-wins) + + private func merge(local: [DatabaseConnection], remote: [DatabaseConnection]) -> [DatabaseConnection] { + var result = local + let localMap = Dictionary(uniqueKeysWithValues: local.map { ($0.id, $0) }) + + for remoteConn in remote { + if localMap[remoteConn.id] != nil { + // Exists locally — replace with server version (last-write-wins) + if let index = result.firstIndex(where: { $0.id == remoteConn.id }) { + result[index] = remoteConn + } + } else { + // New from server + result.append(remoteConn) + } + } + + return result + } +} + +// SyncError Equatable for token expiry check +extension SyncError: Equatable { + public static func == (lhs: SyncError, rhs: SyncError) -> Bool { + switch (lhs, rhs) { + case (.tokenExpired, .tokenExpired): return true + default: return false + } + } +} diff --git a/TableProMobile/TableProMobile/TableProMobileApp.swift b/TableProMobile/TableProMobile/TableProMobileApp.swift index 753ad6085..b3cb669b3 100644 --- a/TableProMobile/TableProMobile/TableProMobileApp.swift +++ b/TableProMobile/TableProMobile/TableProMobileApp.swift @@ -18,10 +18,13 @@ struct TableProMobileApp: App { .environment(appState) } .onChange(of: scenePhase) { _, phase in - if phase == .background { - Task { - await appState.connectionManager.disconnectAll() - } + switch phase { + case .active: + Task { await appState.syncCoordinator.sync(localConnections: appState.connections) } + case .background: + Task { await appState.connectionManager.disconnectAll() } + default: + break } } } From 31ffe511a15afd509d1bd40a9f7dd2bbe6d6f55f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 3 Apr 2026 12:36:14 +0700 Subject: [PATCH 37/61] fix: add Equatable to SyncStatus for comparison --- .../TableProCore/Sources/TableProSync/SyncConflict.swift | 2 +- .../TableProMobile/TableProMobileRelease.entitlements | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 TableProMobile/TableProMobile/TableProMobileRelease.entitlements diff --git a/Packages/TableProCore/Sources/TableProSync/SyncConflict.swift b/Packages/TableProCore/Sources/TableProSync/SyncConflict.swift index 6d37b7432..786537c26 100644 --- a/Packages/TableProCore/Sources/TableProSync/SyncConflict.swift +++ b/Packages/TableProCore/Sources/TableProSync/SyncConflict.swift @@ -25,7 +25,7 @@ public struct SyncConflict: Identifiable, Sendable { } } -public enum SyncStatus: Sendable { +public enum SyncStatus: Equatable, Sendable { case idle case syncing case error(String) diff --git a/TableProMobile/TableProMobile/TableProMobileRelease.entitlements b/TableProMobile/TableProMobile/TableProMobileRelease.entitlements new file mode 100644 index 000000000..5b983f3cb --- /dev/null +++ b/TableProMobile/TableProMobile/TableProMobileRelease.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.developer.icloud-container-identifiers + + + From 3215a856634900783e2ac577557058c130c3a7ae Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 3 Apr 2026 12:38:36 +0700 Subject: [PATCH 38/61] fix: lazy init CloudKitSyncEngine to prevent CKContainer crash on launch --- .../TableProMobile.xcodeproj/project.pbxproj | 1525 +++++++++++++++++ .../Sync/IOSSyncCoordinator.swift | 19 +- .../TableProMobileRelease.entitlements | 10 +- 3 files changed, 1547 insertions(+), 7 deletions(-) diff --git a/TableProMobile/TableProMobile.xcodeproj/project.pbxproj b/TableProMobile/TableProMobile.xcodeproj/project.pbxproj index 24e3a8623..9386717b9 100644 --- a/TableProMobile/TableProMobile.xcodeproj/project.pbxproj +++ b/TableProMobile/TableProMobile.xcodeproj/project.pbxproj @@ -7,6 +7,14 @@ objects = { /* Begin PBXBuildFile section */ + 5A87EEED2F7F893000D028D0 /* TableProSync in Frameworks */ = {isa = PBXBuildFile; productRef = 5A87EEEC2F7F893000D028D0 /* TableProSync */; }; + 5AA3133A2F7EA5B4008EBA97 /* LibPQ.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5AA313342F7EA5B4008EBA97 /* LibPQ.xcframework */; }; + 5AA3133C2F7EA5B4008EBA97 /* Hiredis.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5AA313352F7EA5B4008EBA97 /* Hiredis.xcframework */; }; + 5AA3133E2F7EA5B4008EBA97 /* OpenSSL-Crypto.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5AA313362F7EA5B4008EBA97 /* OpenSSL-Crypto.xcframework */; }; + 5AA313402F7EA5B4008EBA97 /* MariaDB.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5AA313372F7EA5B4008EBA97 /* MariaDB.xcframework */; }; + 5AA313422F7EA5B4008EBA97 /* Hiredis-SSL.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5AA313382F7EA5B4008EBA97 /* Hiredis-SSL.xcframework */; }; + 5AA313442F7EA5B4008EBA97 /* OpenSSL-SSL.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5AA313392F7EA5B4008EBA97 /* OpenSSL-SSL.xcframework */; }; + 5AA313542F7EC188008EBA97 /* LibSSH2.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5AA313532F7EC188008EBA97 /* LibSSH2.xcframework */; }; 5AB9F3E92F7C1D03001F3337 /* TableProDatabase in Frameworks */ = {isa = PBXBuildFile; productRef = 5AB9F3E82F7C1D03001F3337 /* TableProDatabase */; }; 5AB9F3EB2F7C1D03001F3337 /* TableProModels in Frameworks */ = {isa = PBXBuildFile; productRef = 5AB9F3EA2F7C1D03001F3337 /* TableProModels */; }; 5AB9F3ED2F7C1D03001F3337 /* TableProPluginKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5AB9F3EC2F7C1D03001F3337 /* TableProPluginKit */; }; @@ -14,6 +22,471 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 5A87ECDD2F7F88F200D028D0 /* AIPromptTemplates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIPromptTemplates.swift; sourceTree = ""; }; + 5A87ECDE2F7F88F200D028D0 /* AIPromptTemplates+InlineSuggest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AIPromptTemplates+InlineSuggest.swift"; sourceTree = ""; }; + 5A87ECDF2F7F88F200D028D0 /* AIProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIProvider.swift; sourceTree = ""; }; + 5A87ECE02F7F88F200D028D0 /* AIProviderFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIProviderFactory.swift; sourceTree = ""; }; + 5A87ECE12F7F88F200D028D0 /* AISchemaContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AISchemaContext.swift; sourceTree = ""; }; + 5A87ECE22F7F88F200D028D0 /* AnthropicProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnthropicProvider.swift; sourceTree = ""; }; + 5A87ECE32F7F88F200D028D0 /* GeminiProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiProvider.swift; sourceTree = ""; }; + 5A87ECE42F7F88F200D028D0 /* InlineSuggestionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineSuggestionManager.swift; sourceTree = ""; }; + 5A87ECE52F7F88F200D028D0 /* OllamaDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OllamaDetector.swift; sourceTree = ""; }; + 5A87ECE62F7F88F200D028D0 /* OpenAICompatibleProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenAICompatibleProvider.swift; sourceTree = ""; }; + 5A87ECE82F7F88F200D028D0 /* CompletionEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionEngine.swift; sourceTree = ""; }; + 5A87ECE92F7F88F200D028D0 /* SQLCompletionItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLCompletionItem.swift; sourceTree = ""; }; + 5A87ECEA2F7F88F200D028D0 /* SQLCompletionProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLCompletionProvider.swift; sourceTree = ""; }; + 5A87ECEB2F7F88F200D028D0 /* SQLContextAnalyzer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLContextAnalyzer.swift; sourceTree = ""; }; + 5A87ECEC2F7F88F200D028D0 /* SQLKeywords.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLKeywords.swift; sourceTree = ""; }; + 5A87ECED2F7F88F200D028D0 /* SQLSchemaProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLSchemaProvider.swift; sourceTree = ""; }; + 5A87ECEF2F7F88F200D028D0 /* AnyChangeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyChangeManager.swift; sourceTree = ""; }; + 5A87ECF02F7F88F200D028D0 /* DataChangeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataChangeManager.swift; sourceTree = ""; }; + 5A87ECF12F7F88F200D028D0 /* DataChangeModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataChangeModels.swift; sourceTree = ""; }; + 5A87ECF22F7F88F200D028D0 /* DataChangeUndoManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataChangeUndoManager.swift; sourceTree = ""; }; + 5A87ECF32F7F88F200D028D0 /* SQLStatementGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLStatementGenerator.swift; sourceTree = ""; }; + 5A87ECF52F7F88F200D028D0 /* ConnectionHealthMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionHealthMonitor.swift; sourceTree = ""; }; + 5A87ECF62F7F88F200D028D0 /* DatabaseDriver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseDriver.swift; sourceTree = ""; }; + 5A87ECF72F7F88F200D028D0 /* DatabaseManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseManager.swift; sourceTree = ""; }; + 5A87ECF82F7F88F200D028D0 /* FilterSQLGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterSQLGenerator.swift; sourceTree = ""; }; + 5A87ECF92F7F88F200D028D0 /* SQLEscaping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLEscaping.swift; sourceTree = ""; }; + 5A87ECFB2F7F88F200D028D0 /* KeyCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyCode.swift; sourceTree = ""; }; + 5A87ECFC2F7F88F200D028D0 /* PasteboardActionRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteboardActionRouter.swift; sourceTree = ""; }; + 5A87ECFD2F7F88F200D028D0 /* ResponderChainActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResponderChainActions.swift; sourceTree = ""; }; + 5A87ECFF2F7F88F200D028D0 /* DownloadCountService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadCountService.swift; sourceTree = ""; }; + 5A87ED002F7F88F200D028D0 /* PluginInstallTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginInstallTracker.swift; sourceTree = ""; }; + 5A87ED012F7F88F200D028D0 /* PluginManager+Registry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PluginManager+Registry.swift"; sourceTree = ""; }; + 5A87ED022F7F88F200D028D0 /* RegistryClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistryClient.swift; sourceTree = ""; }; + 5A87ED032F7F88F200D028D0 /* RegistryModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistryModels.swift; sourceTree = ""; }; + 5A87ED052F7F88F200D028D0 /* ExportDataSourceAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportDataSourceAdapter.swift; sourceTree = ""; }; + 5A87ED062F7F88F200D028D0 /* ImportDataSinkAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportDataSinkAdapter.swift; sourceTree = ""; }; + 5A87ED072F7F88F200D028D0 /* PluginDriverAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginDriverAdapter.swift; sourceTree = ""; }; + 5A87ED082F7F88F200D028D0 /* PluginError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginError.swift; sourceTree = ""; }; + 5A87ED092F7F88F200D028D0 /* PluginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginManager.swift; sourceTree = ""; }; + 5A87ED0A2F7F88F200D028D0 /* PluginMetadataRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginMetadataRegistry.swift; sourceTree = ""; }; + 5A87ED0B2F7F88F200D028D0 /* PluginMetadataRegistry+CloudDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PluginMetadataRegistry+CloudDefaults.swift"; sourceTree = ""; }; + 5A87ED0C2F7F88F200D028D0 /* PluginMetadataRegistry+RegistryDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PluginMetadataRegistry+RegistryDefaults.swift"; sourceTree = ""; }; + 5A87ED0D2F7F88F200D028D0 /* PluginModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginModels.swift; sourceTree = ""; }; + 5A87ED0E2F7F88F200D028D0 /* QueryResultExportDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryResultExportDataSource.swift; sourceTree = ""; }; + 5A87ED0F2F7F88F200D028D0 /* SqlFileImportSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SqlFileImportSource.swift; sourceTree = ""; }; + 5A87ED112F7F88F200D028D0 /* SchemaStatementGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchemaStatementGenerator.swift; sourceTree = ""; }; + 5A87ED122F7F88F200D028D0 /* StructureChangeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StructureChangeManager.swift; sourceTree = ""; }; + 5A87ED132F7F88F200D028D0 /* StructureUndoManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StructureUndoManager.swift; sourceTree = ""; }; + 5A87ED152F7F88F200D028D0 /* ConnectionExportCrypto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionExportCrypto.swift; sourceTree = ""; }; + 5A87ED162F7F88F200D028D0 /* ConnectionExportService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionExportService.swift; sourceTree = ""; }; + 5A87ED172F7F88F200D028D0 /* ExportService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportService.swift; sourceTree = ""; }; + 5A87ED182F7F88F200D028D0 /* ImportService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportService.swift; sourceTree = ""; }; + 5A87ED192F7F88F200D028D0 /* LinkedFolderWatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkedFolderWatcher.swift; sourceTree = ""; }; + 5A87ED1A2F7F88F200D028D0 /* ProgressUpdateCoalescer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressUpdateCoalescer.swift; sourceTree = ""; }; + 5A87ED1C2F7F88F200D028D0 /* BlobFormattingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlobFormattingService.swift; sourceTree = ""; }; + 5A87ED1D2F7F88F200D028D0 /* CellDisplayFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellDisplayFormatter.swift; sourceTree = ""; }; + 5A87ED1E2F7F88F200D028D0 /* DateFormattingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFormattingService.swift; sourceTree = ""; }; + 5A87ED1F2F7F88F200D028D0 /* SQLFormatterService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLFormatterService.swift; sourceTree = ""; }; + 5A87ED202F7F88F200D028D0 /* SQLFormatterTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLFormatterTypes.swift; sourceTree = ""; }; + 5A87ED222F7F88F200D028D0 /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = ""; }; + 5A87ED232F7F88F200D028D0 /* AppNotifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNotifications.swift; sourceTree = ""; }; + 5A87ED242F7F88F200D028D0 /* ClipboardService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardService.swift; sourceTree = ""; }; + 5A87ED252F7F88F200D028D0 /* DeeplinkHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeeplinkHandler.swift; sourceTree = ""; }; + 5A87ED262F7F88F200D028D0 /* PreConnectHookRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreConnectHookRunner.swift; sourceTree = ""; }; + 5A87ED272F7F88F200D028D0 /* SafeModeGuard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafeModeGuard.swift; sourceTree = ""; }; + 5A87ED282F7F88F200D028D0 /* SessionStateFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionStateFactory.swift; sourceTree = ""; }; + 5A87ED292F7F88F200D028D0 /* SettingsNotifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsNotifications.swift; sourceTree = ""; }; + 5A87ED2A2F7F88F200D028D0 /* SettingsValidation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsValidation.swift; sourceTree = ""; }; + 5A87ED2B2F7F88F200D028D0 /* SQLFileService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLFileService.swift; sourceTree = ""; }; + 5A87ED2C2F7F88F200D028D0 /* TabPersistenceCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabPersistenceCoordinator.swift; sourceTree = ""; }; + 5A87ED2D2F7F88F200D028D0 /* UpdaterBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdaterBridge.swift; sourceTree = ""; }; + 5A87ED2E2F7F88F200D028D0 /* WindowLifecycleMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowLifecycleMonitor.swift; sourceTree = ""; }; + 5A87ED2F2F7F88F200D028D0 /* WindowOpener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowOpener.swift; sourceTree = ""; }; + 5A87ED312F7F88F200D028D0 /* LicenseAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LicenseAPIClient.swift; sourceTree = ""; }; + 5A87ED322F7F88F200D028D0 /* LicenseConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LicenseConstants.swift; sourceTree = ""; }; + 5A87ED332F7F88F200D028D0 /* LicenseManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LicenseManager.swift; sourceTree = ""; }; + 5A87ED342F7F88F200D028D0 /* LicenseManager+Pro.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LicenseManager+Pro.swift"; sourceTree = ""; }; + 5A87ED352F7F88F200D028D0 /* LicenseSignatureVerifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LicenseSignatureVerifier.swift; sourceTree = ""; }; + 5A87ED372F7F88F200D028D0 /* ColumnExclusionPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColumnExclusionPolicy.swift; sourceTree = ""; }; + 5A87ED382F7F88F200D028D0 /* RowOperationsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowOperationsManager.swift; sourceTree = ""; }; + 5A87ED392F7F88F200D028D0 /* RowParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowParser.swift; sourceTree = ""; }; + 5A87ED3A2F7F88F200D028D0 /* SchemaProviderRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchemaProviderRegistry.swift; sourceTree = ""; }; + 5A87ED3B2F7F88F200D028D0 /* SQLDialectProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLDialectProvider.swift; sourceTree = ""; }; + 5A87ED3C2F7F88F200D028D0 /* SQLFunctionProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLFunctionProvider.swift; sourceTree = ""; }; + 5A87ED3D2F7F88F200D028D0 /* TableQueryBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableQueryBuilder.swift; sourceTree = ""; }; + 5A87ED3F2F7F88F200D028D0 /* ColumnType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColumnType.swift; sourceTree = ""; }; + 5A87ED402F7F88F200D028D0 /* ColumnTypeClassifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColumnTypeClassifier.swift; sourceTree = ""; }; + 5A87ED422F7F88F200D028D0 /* AgentAuthenticator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentAuthenticator.swift; sourceTree = ""; }; + 5A87ED432F7F88F200D028D0 /* CompositeAuthenticator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositeAuthenticator.swift; sourceTree = ""; }; + 5A87ED442F7F88F200D028D0 /* KeyboardInteractiveAuthenticator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardInteractiveAuthenticator.swift; sourceTree = ""; }; + 5A87ED452F7F88F200D028D0 /* PasswordAuthenticator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordAuthenticator.swift; sourceTree = ""; }; + 5A87ED462F7F88F200D028D0 /* PromptTOTPProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptTOTPProvider.swift; sourceTree = ""; }; + 5A87ED472F7F88F200D028D0 /* PublicKeyAuthenticator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicKeyAuthenticator.swift; sourceTree = ""; }; + 5A87ED482F7F88F200D028D0 /* SSHAuthenticator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHAuthenticator.swift; sourceTree = ""; }; + 5A87ED492F7F88F200D028D0 /* TOTPProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPProvider.swift; sourceTree = ""; }; + 5A87ED4B2F7F88F200D028D0 /* .gitkeep */ = {isa = PBXFileReference; lastKnownFileType = text; path = .gitkeep; sourceTree = ""; }; + 5A87ED4C2F7F88F200D028D0 /* libssh2.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = libssh2.h; sourceTree = ""; }; + 5A87ED4D2F7F88F200D028D0 /* libssh2_publickey.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = libssh2_publickey.h; sourceTree = ""; }; + 5A87ED4E2F7F88F200D028D0 /* libssh2_sftp.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = libssh2_sftp.h; sourceTree = ""; }; + 5A87ED502F7F88F200D028D0 /* CLibSSH2.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CLibSSH2.h; sourceTree = ""; }; + 5A87ED512F7F88F200D028D0 /* module.modulemap */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.module-map"; path = module.modulemap; sourceTree = ""; }; + 5A87ED532F7F88F200D028D0 /* Base32.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Base32.swift; sourceTree = ""; }; + 5A87ED542F7F88F200D028D0 /* TOTPGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPGenerator.swift; sourceTree = ""; }; + 5A87ED562F7F88F200D028D0 /* HostKeyStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostKeyStore.swift; sourceTree = ""; }; + 5A87ED572F7F88F200D028D0 /* HostKeyVerifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostKeyVerifier.swift; sourceTree = ""; }; + 5A87ED582F7F88F200D028D0 /* LibSSH2Tunnel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibSSH2Tunnel.swift; sourceTree = ""; }; + 5A87ED592F7F88F200D028D0 /* LibSSH2TunnelFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibSSH2TunnelFactory.swift; sourceTree = ""; }; + 5A87ED5A2F7F88F200D028D0 /* SSHConfigParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHConfigParser.swift; sourceTree = ""; }; + 5A87ED5B2F7F88F200D028D0 /* SSHPathUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHPathUtilities.swift; sourceTree = ""; }; + 5A87ED5C2F7F88F200D028D0 /* SSHTunnelManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHTunnelManager.swift; sourceTree = ""; }; + 5A87ED5E2F7F88F200D028D0 /* AIChatStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatStorage.swift; sourceTree = ""; }; + 5A87ED5F2F7F88F200D028D0 /* AIKeyStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIKeyStorage.swift; sourceTree = ""; }; + 5A87ED602F7F88F200D028D0 /* AppSettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsManager.swift; sourceTree = ""; }; + 5A87ED612F7F88F200D028D0 /* AppSettingsStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsStorage.swift; sourceTree = ""; }; + 5A87ED622F7F88F200D028D0 /* ColumnLayoutStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColumnLayoutStorage.swift; sourceTree = ""; }; + 5A87ED632F7F88F200D028D0 /* ConnectionStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionStorage.swift; sourceTree = ""; }; + 5A87ED642F7F88F200D028D0 /* FilterSettingsStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterSettingsStorage.swift; sourceTree = ""; }; + 5A87ED652F7F88F200D028D0 /* GroupStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupStorage.swift; sourceTree = ""; }; + 5A87ED662F7F88F200D028D0 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = ""; }; + 5A87ED672F7F88F200D028D0 /* LicenseStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LicenseStorage.swift; sourceTree = ""; }; + 5A87ED682F7F88F200D028D0 /* LinkedFolderStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkedFolderStorage.swift; sourceTree = ""; }; + 5A87ED692F7F88F200D028D0 /* QueryHistoryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryHistoryManager.swift; sourceTree = ""; }; + 5A87ED6A2F7F88F200D028D0 /* QueryHistoryStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryHistoryStorage.swift; sourceTree = ""; }; + 5A87ED6B2F7F88F200D028D0 /* SQLFavoriteManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLFavoriteManager.swift; sourceTree = ""; }; + 5A87ED6C2F7F88F200D028D0 /* SQLFavoriteStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLFavoriteStorage.swift; sourceTree = ""; }; + 5A87ED6D2F7F88F200D028D0 /* SSHProfileStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHProfileStorage.swift; sourceTree = ""; }; + 5A87ED6E2F7F88F200D028D0 /* TabDiskActor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabDiskActor.swift; sourceTree = ""; }; + 5A87ED6F2F7F88F200D028D0 /* TagStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagStorage.swift; sourceTree = ""; }; + 5A87ED712F7F88F200D028D0 /* CloudKitSyncEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitSyncEngine.swift; sourceTree = ""; }; + 5A87ED722F7F88F200D028D0 /* ConflictResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConflictResolver.swift; sourceTree = ""; }; + 5A87ED732F7F88F200D028D0 /* SyncChangeTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncChangeTracker.swift; sourceTree = ""; }; + 5A87ED742F7F88F200D028D0 /* SyncCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncCoordinator.swift; sourceTree = ""; }; + 5A87ED752F7F88F200D028D0 /* SyncError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncError.swift; sourceTree = ""; }; + 5A87ED762F7F88F200D028D0 /* SyncMetadataStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncMetadataStorage.swift; sourceTree = ""; }; + 5A87ED772F7F88F200D028D0 /* SyncRecordMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncRecordMapper.swift; sourceTree = ""; }; + 5A87ED782F7F88F200D028D0 /* SyncStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncStatus.swift; sourceTree = ""; }; + 5A87ED7A2F7F88F200D028D0 /* ConnectionURLFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionURLFormatter.swift; sourceTree = ""; }; + 5A87ED7B2F7F88F200D028D0 /* ConnectionURLParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionURLParser.swift; sourceTree = ""; }; + 5A87ED7C2F7F88F200D028D0 /* EnvVarResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvVarResolver.swift; sourceTree = ""; }; + 5A87ED7D2F7F88F200D028D0 /* ExponentialBackoff.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExponentialBackoff.swift; sourceTree = ""; }; + 5A87ED7E2F7F88F200D028D0 /* PgpassReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PgpassReader.swift; sourceTree = ""; }; + 5A87ED802F7F88F200D028D0 /* FileDecompressor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDecompressor.swift; sourceTree = ""; }; + 5A87ED822F7F88F200D028D0 /* DialectQuoteHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DialectQuoteHelper.swift; sourceTree = ""; }; + 5A87ED832F7F88F200D028D0 /* JsonRowConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JsonRowConverter.swift; sourceTree = ""; }; + 5A87ED842F7F88F200D028D0 /* RowSortComparator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowSortComparator.swift; sourceTree = ""; }; + 5A87ED852F7F88F200D028D0 /* SQLFileParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLFileParser.swift; sourceTree = ""; }; + 5A87ED862F7F88F200D028D0 /* SQLParameterInliner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLParameterInliner.swift; sourceTree = ""; }; + 5A87ED872F7F88F200D028D0 /* SQLRowToStatementConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLRowToStatementConverter.swift; sourceTree = ""; }; + 5A87ED882F7F88F200D028D0 /* SQLStatementScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLStatementScanner.swift; sourceTree = ""; }; + 5A87ED8A2F7F88F200D028D0 /* AlertHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertHelper.swift; sourceTree = ""; }; + 5A87ED8B2F7F88F200D028D0 /* FuzzyMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuzzyMatcher.swift; sourceTree = ""; }; + 5A87ED8C2F7F88F200D028D0 /* PasswordPromptHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordPromptHelper.swift; sourceTree = ""; }; + 5A87ED8E2F7F88F200D028D0 /* MemoryPressureAdvisor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryPressureAdvisor.swift; sourceTree = ""; }; + 5A87ED902F7F88F200D028D0 /* VimCommandLineHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VimCommandLineHandler.swift; sourceTree = ""; }; + 5A87ED912F7F88F200D028D0 /* VimCursorManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VimCursorManager.swift; sourceTree = ""; }; + 5A87ED922F7F88F200D028D0 /* VimEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VimEngine.swift; sourceTree = ""; }; + 5A87ED932F7F88F200D028D0 /* VimKeyInterceptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VimKeyInterceptor.swift; sourceTree = ""; }; + 5A87ED942F7F88F200D028D0 /* VimMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VimMode.swift; sourceTree = ""; }; + 5A87ED952F7F88F200D028D0 /* VimRegister.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VimRegister.swift; sourceTree = ""; }; + 5A87ED962F7F88F200D028D0 /* VimTextBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VimTextBuffer.swift; sourceTree = ""; }; + 5A87ED972F7F88F200D028D0 /* VimTextBufferAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VimTextBufferAdapter.swift; sourceTree = ""; }; + 5A87ED9A2F7F88F200D028D0 /* Bundle+AppInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+AppInfo.swift"; sourceTree = ""; }; + 5A87ED9B2F7F88F200D028D0 /* Color+Hex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Hex.swift"; sourceTree = ""; }; + 5A87ED9C2F7F88F200D028D0 /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = ""; }; + 5A87ED9D2F7F88F200D028D0 /* EditorLanguage+TreeSitter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EditorLanguage+TreeSitter.swift"; sourceTree = ""; }; + 5A87ED9E2F7F88F200D028D0 /* NSApplication+WindowManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSApplication+WindowManagement.swift"; sourceTree = ""; }; + 5A87ED9F2F7F88F200D028D0 /* NSView+Focus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSView+Focus.swift"; sourceTree = ""; }; + 5A87EDA02F7F88F200D028D0 /* NSViewController+SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSViewController+SwiftUI.swift"; sourceTree = ""; }; + 5A87EDA12F7F88F200D028D0 /* String+HexDump.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+HexDump.swift"; sourceTree = ""; }; + 5A87EDA22F7F88F200D028D0 /* String+JSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+JSON.swift"; sourceTree = ""; }; + 5A87EDA32F7F88F200D028D0 /* String+SHA256.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+SHA256.swift"; sourceTree = ""; }; + 5A87EDA42F7F88F200D028D0 /* UserDefaults+RecentDatabases.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults+RecentDatabases.swift"; sourceTree = ""; }; + 5A87EDA52F7F88F200D028D0 /* View+OptionalShortcut.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+OptionalShortcut.swift"; sourceTree = ""; }; + 5A87EDA72F7F88F200D028D0 /* AIConversation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIConversation.swift; sourceTree = ""; }; + 5A87EDA82F7F88F200D028D0 /* AIModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIModels.swift; sourceTree = ""; }; + 5A87EDAA2F7F88F200D028D0 /* ClickHouseExplainVariant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClickHouseExplainVariant.swift; sourceTree = ""; }; + 5A87EDAB2F7F88F200D028D0 /* ClickHousePartInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClickHousePartInfo.swift; sourceTree = ""; }; + 5A87EDAC2F7F88F200D028D0 /* ClickHouseQueryProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClickHouseQueryProgress.swift; sourceTree = ""; }; + 5A87EDAE2F7F88F200D028D0 /* ConnectionExport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionExport.swift; sourceTree = ""; }; + 5A87EDAF2F7F88F200D028D0 /* ConnectionGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionGroup.swift; sourceTree = ""; }; + 5A87EDB02F7F88F200D028D0 /* ConnectionGroupTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionGroupTree.swift; sourceTree = ""; }; + 5A87EDB12F7F88F200D028D0 /* ConnectionSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionSession.swift; sourceTree = ""; }; + 5A87EDB22F7F88F200D028D0 /* ConnectionTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionTag.swift; sourceTree = ""; }; + 5A87EDB32F7F88F200D028D0 /* ConnectionToolbarState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionToolbarState.swift; sourceTree = ""; }; + 5A87EDB42F7F88F200D028D0 /* DatabaseConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseConnection.swift; sourceTree = ""; }; + 5A87EDB52F7F88F200D028D0 /* DatabaseConnection+SSH.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DatabaseConnection+SSH.swift"; sourceTree = ""; }; + 5A87EDB62F7F88F200D028D0 /* SafeModeLevel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafeModeLevel.swift; sourceTree = ""; }; + 5A87EDB72F7F88F200D028D0 /* SSHProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHProfile.swift; sourceTree = ""; }; + 5A87EDB82F7F88F200D028D0 /* TOTPConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPConfiguration.swift; sourceTree = ""; }; + 5A87EDBA2F7F88F200D028D0 /* DatabaseMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseMetadata.swift; sourceTree = ""; }; + 5A87EDBB2F7F88F200D028D0 /* TableFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableFilter.swift; sourceTree = ""; }; + 5A87EDBC2F7F88F200D028D0 /* TableMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableMetadata.swift; sourceTree = ""; }; + 5A87EDBD2F7F88F200D028D0 /* TableOperationOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableOperationOptions.swift; sourceTree = ""; }; + 5A87EDBE2F7F88F200D028D0 /* TableSchema.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableSchema.swift; sourceTree = ""; }; + 5A87EDC02F7F88F200D028D0 /* ExportModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportModels.swift; sourceTree = ""; }; + 5A87EDC12F7F88F200D028D0 /* ImportModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportModels.swift; sourceTree = ""; }; + 5A87EDC32F7F88F200D028D0 /* EditorTabPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorTabPayload.swift; sourceTree = ""; }; + 5A87EDC42F7F88F200D028D0 /* ParsedRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParsedRow.swift; sourceTree = ""; }; + 5A87EDC52F7F88F200D028D0 /* QueryHistoryEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryHistoryEntry.swift; sourceTree = ""; }; + 5A87EDC62F7F88F200D028D0 /* QueryResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryResult.swift; sourceTree = ""; }; + 5A87EDC72F7F88F200D028D0 /* QueryTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryTab.swift; sourceTree = ""; }; + 5A87EDC82F7F88F200D028D0 /* ResultSet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResultSet.swift; sourceTree = ""; }; + 5A87EDC92F7F88F200D028D0 /* RowProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowProvider.swift; sourceTree = ""; }; + 5A87EDCA2F7F88F200D028D0 /* SQLFavorite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLFavorite.swift; sourceTree = ""; }; + 5A87EDCB2F7F88F200D028D0 /* SQLFavoriteFolder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLFavoriteFolder.swift; sourceTree = ""; }; + 5A87EDCD2F7F88F200D028D0 /* ColumnDefinition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColumnDefinition.swift; sourceTree = ""; }; + 5A87EDCE2F7F88F200D028D0 /* CreateTableOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateTableOptions.swift; sourceTree = ""; }; + 5A87EDCF2F7F88F200D028D0 /* ForeignKeyDefinition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForeignKeyDefinition.swift; sourceTree = ""; }; + 5A87EDD02F7F88F200D028D0 /* IndexDefinition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndexDefinition.swift; sourceTree = ""; }; + 5A87EDD12F7F88F200D028D0 /* SchemaChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchemaChange.swift; sourceTree = ""; }; + 5A87EDD22F7F88F200D028D0 /* SchemaChange+Undo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SchemaChange+Undo.swift"; sourceTree = ""; }; + 5A87EDD32F7F88F200D028D0 /* StructureTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StructureTab.swift; sourceTree = ""; }; + 5A87EDD52F7F88F200D028D0 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; + 5A87EDD62F7F88F200D028D0 /* License.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = License.swift; sourceTree = ""; }; + 5A87EDD72F7F88F200D028D0 /* ProFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProFeature.swift; sourceTree = ""; }; + 5A87EDD82F7F88F200D028D0 /* SyncSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncSettings.swift; sourceTree = ""; }; + 5A87EDDA2F7F88F200D028D0 /* ColumnVisibilityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColumnVisibilityManager.swift; sourceTree = ""; }; + 5A87EDDB2F7F88F200D028D0 /* FilterPreset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterPreset.swift; sourceTree = ""; }; + 5A87EDDC2F7F88F200D028D0 /* FilterState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterState.swift; sourceTree = ""; }; + 5A87EDDD2F7F88F200D028D0 /* InspectorContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectorContext.swift; sourceTree = ""; }; + 5A87EDDE2F7F88F200D028D0 /* KeyboardShortcutModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutModels.swift; sourceTree = ""; }; + 5A87EDDF2F7F88F200D028D0 /* MultiRowEditState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiRowEditState.swift; sourceTree = ""; }; + 5A87EDE02F7F88F200D028D0 /* QuickSwitcherItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickSwitcherItem.swift; sourceTree = ""; }; + 5A87EDE12F7F88F200D028D0 /* RedisKeyNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedisKeyNode.swift; sourceTree = ""; }; + 5A87EDE22F7F88F200D028D0 /* RightPanelState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightPanelState.swift; sourceTree = ""; }; + 5A87EDE32F7F88F200D028D0 /* RightPanelTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightPanelTab.swift; sourceTree = ""; }; + 5A87EDE42F7F88F200D028D0 /* SharedSidebarState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedSidebarState.swift; sourceTree = ""; }; + 5A87EDE72F7F88F200D028D0 /* tablepro.default-dark.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "tablepro.default-dark.json"; sourceTree = ""; }; + 5A87EDE82F7F88F200D028D0 /* tablepro.default-light.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "tablepro.default-light.json"; sourceTree = ""; }; + 5A87EDE92F7F88F200D028D0 /* tablepro.dracula.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = tablepro.dracula.json; sourceTree = ""; }; + 5A87EDEA2F7F88F200D028D0 /* tablepro.nord.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = tablepro.nord.json; sourceTree = ""; }; + 5A87EDEC2F7F88F200D028D0 /* license_public.pem */ = {isa = PBXFileReference; lastKnownFileType = text; path = license_public.pem; sourceTree = ""; }; + 5A87EDED2F7F88F200D028D0 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + 5A87EDEE2F7F88F200D028D0 /* SQLDocument.icns */ = {isa = PBXFileReference; lastKnownFileType = image.icns; path = SQLDocument.icns; sourceTree = ""; }; + 5A87EDF02F7F88F200D028D0 /* HexColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HexColor.swift; sourceTree = ""; }; + 5A87EDF12F7F88F200D028D0 /* RegistryThemeMeta.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistryThemeMeta.swift; sourceTree = ""; }; + 5A87EDF22F7F88F200D028D0 /* ResolvedThemeColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResolvedThemeColors.swift; sourceTree = ""; }; + 5A87EDF32F7F88F200D028D0 /* ThemeDefinition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeDefinition.swift; sourceTree = ""; }; + 5A87EDF42F7F88F200D028D0 /* ThemeEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeEngine.swift; sourceTree = ""; }; + 5A87EDF52F7F88F200D028D0 /* ThemeRegistryInstaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeRegistryInstaller.swift; sourceTree = ""; }; + 5A87EDF62F7F88F200D028D0 /* ThemeStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeStorage.swift; sourceTree = ""; }; + 5A87EDF82F7F88F200D028D0 /* AIChatViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatViewModel.swift; sourceTree = ""; }; + 5A87EDF92F7F88F200D028D0 /* DatabaseSwitcherViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseSwitcherViewModel.swift; sourceTree = ""; }; + 5A87EDFA2F7F88F200D028D0 /* FavoritesSidebarViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesSidebarViewModel.swift; sourceTree = ""; }; + 5A87EDFB2F7F88F200D028D0 /* QuickSwitcherViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickSwitcherViewModel.swift; sourceTree = ""; }; + 5A87EDFC2F7F88F200D028D0 /* RedisKeyTreeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedisKeyTreeViewModel.swift; sourceTree = ""; }; + 5A87EDFD2F7F88F200D028D0 /* SidebarViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarViewModel.swift; sourceTree = ""; }; + 5A87EDFE2F7F88F200D028D0 /* WelcomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeViewModel.swift; sourceTree = ""; }; + 5A87EE002F7F88F200D028D0 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; + 5A87EE012F7F88F200D028D0 /* AboutWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutWindowController.swift; sourceTree = ""; }; + 5A87EE032F7F88F200D028D0 /* AIChatCodeBlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatCodeBlockView.swift; sourceTree = ""; }; + 5A87EE042F7F88F200D028D0 /* AIChatMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatMessageView.swift; sourceTree = ""; }; + 5A87EE052F7F88F200D028D0 /* AIChatPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatPanelView.swift; sourceTree = ""; }; + 5A87EE072F7F88F200D028D0 /* ConflictResolutionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConflictResolutionView.swift; sourceTree = ""; }; + 5A87EE082F7F88F200D028D0 /* EmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyStateView.swift; sourceTree = ""; }; + 5A87EE092F7F88F200D028D0 /* HighlightedSQLTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightedSQLTextView.swift; sourceTree = ""; }; + 5A87EE0A2F7F88F200D028D0 /* PaginationControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationControlsView.swift; sourceTree = ""; }; + 5A87EE0B2F7F88F200D028D0 /* PanelResizeHandle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PanelResizeHandle.swift; sourceTree = ""; }; + 5A87EE0C2F7F88F200D028D0 /* PopoverPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopoverPresenter.swift; sourceTree = ""; }; + 5A87EE0D2F7F88F200D028D0 /* ProFeatureGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProFeatureGate.swift; sourceTree = ""; }; + 5A87EE0E2F7F88F200D028D0 /* SectionHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SectionHeaderView.swift; sourceTree = ""; }; + 5A87EE0F2F7F88F200D028D0 /* SQLReviewPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLReviewPopover.swift; sourceTree = ""; }; + 5A87EE102F7F88F200D028D0 /* SyncStatusIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncStatusIndicator.swift; sourceTree = ""; }; + 5A87EE112F7F88F200D028D0 /* WindowAccessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAccessor.swift; sourceTree = ""; }; + 5A87EE132F7F88F200D028D0 /* ConnectionAdvancedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionAdvancedView.swift; sourceTree = ""; }; + 5A87EE142F7F88F200D028D0 /* ConnectionColorPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionColorPicker.swift; sourceTree = ""; }; + 5A87EE152F7F88F200D028D0 /* ConnectionExportOptionsSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionExportOptionsSheet.swift; sourceTree = ""; }; + 5A87EE162F7F88F200D028D0 /* ConnectionFieldRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionFieldRow.swift; sourceTree = ""; }; + 5A87EE172F7F88F200D028D0 /* ConnectionFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionFormView.swift; sourceTree = ""; }; + 5A87EE182F7F88F200D028D0 /* ConnectionGroupPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionGroupPicker.swift; sourceTree = ""; }; + 5A87EE192F7F88F200D028D0 /* ConnectionImportSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionImportSheet.swift; sourceTree = ""; }; + 5A87EE1A2F7F88F200D028D0 /* ConnectionSidebarHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionSidebarHeader.swift; sourceTree = ""; }; + 5A87EE1B2F7F88F200D028D0 /* ConnectionSSHTunnelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionSSHTunnelView.swift; sourceTree = ""; }; + 5A87EE1C2F7F88F200D028D0 /* ConnectionSSLView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionSSLView.swift; sourceTree = ""; }; + 5A87EE1D2F7F88F200D028D0 /* ConnectionTagEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionTagEditor.swift; sourceTree = ""; }; + 5A87EE1E2F7F88F200D028D0 /* OnboardingContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingContentView.swift; sourceTree = ""; }; + 5A87EE1F2F7F88F200D028D0 /* PasswordPromptToggle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordPromptToggle.swift; sourceTree = ""; }; + 5A87EE202F7F88F200D028D0 /* PluginInstallModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginInstallModifier.swift; sourceTree = ""; }; + 5A87EE212F7F88F200D028D0 /* SSHProfileEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHProfileEditorView.swift; sourceTree = ""; }; + 5A87EE222F7F88F200D028D0 /* WelcomeConnectionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeConnectionRow.swift; sourceTree = ""; }; + 5A87EE232F7F88F200D028D0 /* WelcomeContextMenus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeContextMenus.swift; sourceTree = ""; }; + 5A87EE242F7F88F200D028D0 /* WelcomeLeftPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeLeftPanel.swift; sourceTree = ""; }; + 5A87EE252F7F88F200D028D0 /* WelcomeWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeWindowView.swift; sourceTree = ""; }; + 5A87EE272F7F88F200D028D0 /* CreateDatabaseSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateDatabaseSheet.swift; sourceTree = ""; }; + 5A87EE282F7F88F200D028D0 /* DatabaseSwitcherSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseSwitcherSheet.swift; sourceTree = ""; }; + 5A87EE2A2F7F88F200D028D0 /* AIEditorContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIEditorContextMenu.swift; sourceTree = ""; }; + 5A87EE2B2F7F88F200D028D0 /* EditorEventRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorEventRouter.swift; sourceTree = ""; }; + 5A87EE2C2F7F88F200D028D0 /* ExplainResultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExplainResultView.swift; sourceTree = ""; }; + 5A87EE2D2F7F88F200D028D0 /* HistoryPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryPanelView.swift; sourceTree = ""; }; + 5A87EE2E2F7F88F200D028D0 /* QueryEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryEditorView.swift; sourceTree = ""; }; + 5A87EE2F2F7F88F200D028D0 /* QuerySplitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuerySplitView.swift; sourceTree = ""; }; + 5A87EE302F7F88F200D028D0 /* QuerySuccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuerySuccessView.swift; sourceTree = ""; }; + 5A87EE312F7F88F200D028D0 /* SQLCompletionAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLCompletionAdapter.swift; sourceTree = ""; }; + 5A87EE322F7F88F200D028D0 /* SQLEditorCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLEditorCoordinator.swift; sourceTree = ""; }; + 5A87EE332F7F88F200D028D0 /* SQLEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLEditorView.swift; sourceTree = ""; }; + 5A87EE342F7F88F200D028D0 /* TableProEditorTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableProEditorTheme.swift; sourceTree = ""; }; + 5A87EE352F7F88F200D028D0 /* VimModeIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VimModeIndicatorView.swift; sourceTree = ""; }; + 5A87EE372F7F88F200D028D0 /* ExportDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportDialog.swift; sourceTree = ""; }; + 5A87EE382F7F88F200D028D0 /* ExportProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportProgressView.swift; sourceTree = ""; }; + 5A87EE392F7F88F200D028D0 /* ExportSuccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportSuccessView.swift; sourceTree = ""; }; + 5A87EE3A2F7F88F200D028D0 /* ExportTableTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportTableTreeView.swift; sourceTree = ""; }; + 5A87EE3C2F7F88F200D028D0 /* CompletionTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionTextField.swift; sourceTree = ""; }; + 5A87EE3D2F7F88F200D028D0 /* FilterPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterPanelView.swift; sourceTree = ""; }; + 5A87EE3E2F7F88F200D028D0 /* FilterRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterRowView.swift; sourceTree = ""; }; + 5A87EE3F2F7F88F200D028D0 /* FilterSettingsPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterSettingsPopover.swift; sourceTree = ""; }; + 5A87EE402F7F88F200D028D0 /* MixedStateCheckbox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MixedStateCheckbox.swift; sourceTree = ""; }; + 5A87EE412F7F88F200D028D0 /* SQLPreviewSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLPreviewSheet.swift; sourceTree = ""; }; + 5A87EE432F7F88F200D028D0 /* ImportDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportDialog.swift; sourceTree = ""; }; + 5A87EE442F7F88F200D028D0 /* ImportErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportErrorView.swift; sourceTree = ""; }; + 5A87EE452F7F88F200D028D0 /* ImportProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportProgressView.swift; sourceTree = ""; }; + 5A87EE462F7F88F200D028D0 /* ImportSuccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportSuccessView.swift; sourceTree = ""; }; + 5A87EE472F7F88F200D028D0 /* SQLCodePreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLCodePreview.swift; sourceTree = ""; }; + 5A87EE492F7F88F200D028D0 /* MainEditorContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainEditorContentView.swift; sourceTree = ""; }; + 5A87EE4A2F7F88F200D028D0 /* MainStatusBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainStatusBarView.swift; sourceTree = ""; }; + 5A87EE4C2F7F88F200D028D0 /* MainContentCoordinator+Alerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainContentCoordinator+Alerts.swift"; sourceTree = ""; }; + 5A87EE4D2F7F88F200D028D0 /* MainContentCoordinator+ChangeGuard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainContentCoordinator+ChangeGuard.swift"; sourceTree = ""; }; + 5A87EE4E2F7F88F200D028D0 /* MainContentCoordinator+ClickHouse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainContentCoordinator+ClickHouse.swift"; sourceTree = ""; }; + 5A87EE4F2F7F88F200D028D0 /* MainContentCoordinator+ColumnLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainContentCoordinator+ColumnLayout.swift"; sourceTree = ""; }; + 5A87EE502F7F88F200D028D0 /* MainContentCoordinator+ColumnVisibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainContentCoordinator+ColumnVisibility.swift"; sourceTree = ""; }; + 5A87EE512F7F88F200D028D0 /* MainContentCoordinator+Discard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainContentCoordinator+Discard.swift"; sourceTree = ""; }; + 5A87EE522F7F88F200D028D0 /* MainContentCoordinator+Favorites.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainContentCoordinator+Favorites.swift"; sourceTree = ""; }; + 5A87EE532F7F88F200D028D0 /* MainContentCoordinator+Filtering.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainContentCoordinator+Filtering.swift"; sourceTree = ""; }; + 5A87EE542F7F88F200D028D0 /* MainContentCoordinator+FKNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainContentCoordinator+FKNavigation.swift"; sourceTree = ""; }; + 5A87EE552F7F88F200D028D0 /* MainContentCoordinator+LazyLoadColumns.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainContentCoordinator+LazyLoadColumns.swift"; sourceTree = ""; }; + 5A87EE562F7F88F200D028D0 /* MainContentCoordinator+MongoDB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainContentCoordinator+MongoDB.swift"; sourceTree = ""; }; + 5A87EE572F7F88F200D028D0 /* MainContentCoordinator+MultiStatement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainContentCoordinator+MultiStatement.swift"; sourceTree = ""; }; + 5A87EE582F7F88F200D028D0 /* MainContentCoordinator+Navigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainContentCoordinator+Navigation.swift"; sourceTree = ""; }; + 5A87EE592F7F88F200D028D0 /* MainContentCoordinator+Pagination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainContentCoordinator+Pagination.swift"; sourceTree = ""; }; + 5A87EE5A2F7F88F200D028D0 /* MainContentCoordinator+QueryAnalysis.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainContentCoordinator+QueryAnalysis.swift"; sourceTree = ""; }; + 5A87EE5B2F7F88F200D028D0 /* MainContentCoordinator+QueryHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainContentCoordinator+QueryHelpers.swift"; sourceTree = ""; }; + 5A87EE5C2F7F88F200D028D0 /* MainContentCoordinator+QuickSwitcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainContentCoordinator+QuickSwitcher.swift"; sourceTree = ""; }; + 5A87EE5D2F7F88F200D028D0 /* MainContentCoordinator+Redis.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainContentCoordinator+Redis.swift"; sourceTree = ""; }; + 5A87EE5E2F7F88F200D028D0 /* MainContentCoordinator+Refresh.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainContentCoordinator+Refresh.swift"; sourceTree = ""; }; + 5A87EE5F2F7F88F200D028D0 /* MainContentCoordinator+RowOperations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainContentCoordinator+RowOperations.swift"; sourceTree = ""; }; + 5A87EE602F7F88F200D028D0 /* MainContentCoordinator+SaveChanges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainContentCoordinator+SaveChanges.swift"; sourceTree = ""; }; + 5A87EE612F7F88F200D028D0 /* MainContentCoordinator+SidebarActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainContentCoordinator+SidebarActions.swift"; sourceTree = ""; }; + 5A87EE622F7F88F200D028D0 /* MainContentCoordinator+SidebarSave.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainContentCoordinator+SidebarSave.swift"; sourceTree = ""; }; + 5A87EE632F7F88F200D028D0 /* MainContentCoordinator+SQLPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainContentCoordinator+SQLPreview.swift"; sourceTree = ""; }; + 5A87EE642F7F88F200D028D0 /* MainContentCoordinator+TableOperations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainContentCoordinator+TableOperations.swift"; sourceTree = ""; }; + 5A87EE652F7F88F200D028D0 /* MainContentCoordinator+TabSwitch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainContentCoordinator+TabSwitch.swift"; sourceTree = ""; }; + 5A87EE662F7F88F200D028D0 /* MainContentCoordinator+URLFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainContentCoordinator+URLFilter.swift"; sourceTree = ""; }; + 5A87EE672F7F88F200D028D0 /* MainContentView+Bindings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainContentView+Bindings.swift"; sourceTree = ""; }; + 5A87EE692F7F88F200D028D0 /* MainContentCommandActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentCommandActions.swift; sourceTree = ""; }; + 5A87EE6A2F7F88F200D028D0 /* MainContentCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentCoordinator.swift; sourceTree = ""; }; + 5A87EE6B2F7F88F200D028D0 /* MainContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentView.swift; sourceTree = ""; }; + 5A87EE6C2F7F88F200D028D0 /* SidebarNavigationResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarNavigationResult.swift; sourceTree = ""; }; + 5A87EE6D2F7F88F200D028D0 /* TableSelectionAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableSelectionAction.swift; sourceTree = ""; }; + 5A87EE6F2F7F88F200D028D0 /* QuickSwitcherView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickSwitcherView.swift; sourceTree = ""; }; + 5A87EE712F7F88F200D028D0 /* DataGridView+Click.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataGridView+Click.swift"; sourceTree = ""; }; + 5A87EE722F7F88F200D028D0 /* DataGridView+Columns.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataGridView+Columns.swift"; sourceTree = ""; }; + 5A87EE732F7F88F200D028D0 /* DataGridView+Editing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataGridView+Editing.swift"; sourceTree = ""; }; + 5A87EE742F7F88F200D028D0 /* DataGridView+Popovers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataGridView+Popovers.swift"; sourceTree = ""; }; + 5A87EE752F7F88F200D028D0 /* DataGridView+Selection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataGridView+Selection.swift"; sourceTree = ""; }; + 5A87EE762F7F88F200D028D0 /* DataGridView+Sort.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataGridView+Sort.swift"; sourceTree = ""; }; + 5A87EE782F7F88F200D028D0 /* BooleanCellEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BooleanCellEditor.swift; sourceTree = ""; }; + 5A87EE792F7F88F200D028D0 /* BooleanCellFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BooleanCellFormatter.swift; sourceTree = ""; }; + 5A87EE7A2F7F88F200D028D0 /* CellOverlayEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellOverlayEditor.swift; sourceTree = ""; }; + 5A87EE7B2F7F88F200D028D0 /* CellTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellTextField.swift; sourceTree = ""; }; + 5A87EE7C2F7F88F200D028D0 /* ColumnVisibilityPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColumnVisibilityPopover.swift; sourceTree = ""; }; + 5A87EE7D2F7F88F200D028D0 /* DataGridCellFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataGridCellFactory.swift; sourceTree = ""; }; + 5A87EE7E2F7F88F200D028D0 /* DataGridCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataGridCoordinator.swift; sourceTree = ""; }; + 5A87EE7F2F7F88F200D028D0 /* DataGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataGridView.swift; sourceTree = ""; }; + 5A87EE802F7F88F200D028D0 /* DataGridView+RowActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataGridView+RowActions.swift"; sourceTree = ""; }; + 5A87EE812F7F88F200D028D0 /* DataGridView+TypePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataGridView+TypePicker.swift"; sourceTree = ""; }; + 5A87EE822F7F88F200D028D0 /* DatePickerCellEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatePickerCellEditor.swift; sourceTree = ""; }; + 5A87EE832F7F88F200D028D0 /* EnumPopoverContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnumPopoverContentView.swift; sourceTree = ""; }; + 5A87EE842F7F88F200D028D0 /* ForeignKeyPopoverContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForeignKeyPopoverContentView.swift; sourceTree = ""; }; + 5A87EE852F7F88F200D028D0 /* HexEditorContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HexEditorContentView.swift; sourceTree = ""; }; + 5A87EE862F7F88F200D028D0 /* HistoryDataProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryDataProvider.swift; sourceTree = ""; }; + 5A87EE872F7F88F200D028D0 /* InlineErrorBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineErrorBanner.swift; sourceTree = ""; }; + 5A87EE882F7F88F200D028D0 /* JSONBraceMatchingHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONBraceMatchingHelper.swift; sourceTree = ""; }; + 5A87EE892F7F88F200D028D0 /* JSONEditorContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONEditorContentView.swift; sourceTree = ""; }; + 5A87EE8A2F7F88F200D028D0 /* JSONHighlightPatterns.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONHighlightPatterns.swift; sourceTree = ""; }; + 5A87EE8B2F7F88F200D028D0 /* JSONSyntaxTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONSyntaxTextView.swift; sourceTree = ""; }; + 5A87EE8C2F7F88F200D028D0 /* KeyHandlingTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyHandlingTableView.swift; sourceTree = ""; }; + 5A87EE8D2F7F88F200D028D0 /* ResultSuccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResultSuccessView.swift; sourceTree = ""; }; + 5A87EE8E2F7F88F200D028D0 /* ResultTabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResultTabBar.swift; sourceTree = ""; }; + 5A87EE8F2F7F88F200D028D0 /* SetPopoverContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetPopoverContentView.swift; sourceTree = ""; }; + 5A87EE902F7F88F200D028D0 /* TableRowViewWithMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableRowViewWithMenu.swift; sourceTree = ""; }; + 5A87EE922F7F88F200D028D0 /* BlobHexEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlobHexEditorView.swift; sourceTree = ""; }; + 5A87EE932F7F88F200D028D0 /* BooleanPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BooleanPickerView.swift; sourceTree = ""; }; + 5A87EE942F7F88F200D028D0 /* DropdownFieldHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DropdownFieldHelper.swift; sourceTree = ""; }; + 5A87EE952F7F88F200D028D0 /* EnumPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnumPickerView.swift; sourceTree = ""; }; + 5A87EE962F7F88F200D028D0 /* FieldEditorContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldEditorContext.swift; sourceTree = ""; }; + 5A87EE972F7F88F200D028D0 /* FieldEditorResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldEditorResolver.swift; sourceTree = ""; }; + 5A87EE982F7F88F200D028D0 /* FieldMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldMenuView.swift; sourceTree = ""; }; + 5A87EE992F7F88F200D028D0 /* JsonEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JsonEditorView.swift; sourceTree = ""; }; + 5A87EE9A2F7F88F200D028D0 /* MultiLineEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiLineEditorView.swift; sourceTree = ""; }; + 5A87EE9B2F7F88F200D028D0 /* PendingStateOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingStateOverlay.swift; sourceTree = ""; }; + 5A87EE9C2F7F88F200D028D0 /* SetPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetPickerView.swift; sourceTree = ""; }; + 5A87EE9D2F7F88F200D028D0 /* SingleLineEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleLineEditorView.swift; sourceTree = ""; }; + 5A87EE9F2F7F88F200D028D0 /* EditableFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditableFieldView.swift; sourceTree = ""; }; + 5A87EEA02F7F88F200D028D0 /* RightSidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarView.swift; sourceTree = ""; }; + 5A87EEA12F7F88F200D028D0 /* UnifiedRightPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnifiedRightPanelView.swift; sourceTree = ""; }; + 5A87EEA32F7F88F200D028D0 /* ThemeEditorColorsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeEditorColorsSection.swift; sourceTree = ""; }; + 5A87EEA42F7F88F200D028D0 /* ThemeEditorFontsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeEditorFontsSection.swift; sourceTree = ""; }; + 5A87EEA52F7F88F200D028D0 /* ThemeEditorLayoutSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeEditorLayoutSection.swift; sourceTree = ""; }; + 5A87EEA62F7F88F200D028D0 /* ThemeEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeEditorView.swift; sourceTree = ""; }; + 5A87EEA72F7F88F200D028D0 /* ThemeListRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeListRowView.swift; sourceTree = ""; }; + 5A87EEA82F7F88F200D028D0 /* ThemeListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeListView.swift; sourceTree = ""; }; + 5A87EEAA2F7F88F200D028D0 /* BrowsePluginsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowsePluginsView.swift; sourceTree = ""; }; + 5A87EEAB2F7F88F200D028D0 /* InstalledPluginsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstalledPluginsView.swift; sourceTree = ""; }; + 5A87EEAC2F7F88F200D028D0 /* PluginIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginIconView.swift; sourceTree = ""; }; + 5A87EEAD2F7F88F200D028D0 /* RegistryPluginDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistryPluginDetailView.swift; sourceTree = ""; }; + 5A87EEAF2F7F88F200D028D0 /* AISettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AISettingsView.swift; sourceTree = ""; }; + 5A87EEB02F7F88F200D028D0 /* AppearanceSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceSettingsView.swift; sourceTree = ""; }; + 5A87EEB12F7F88F200D028D0 /* DataGridSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataGridSettingsView.swift; sourceTree = ""; }; + 5A87EEB22F7F88F200D028D0 /* EditorSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorSettingsView.swift; sourceTree = ""; }; + 5A87EEB32F7F88F200D028D0 /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = ""; }; + 5A87EEB42F7F88F200D028D0 /* HistorySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistorySettingsView.swift; sourceTree = ""; }; + 5A87EEB52F7F88F200D028D0 /* KeyboardSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardSettingsView.swift; sourceTree = ""; }; + 5A87EEB62F7F88F200D028D0 /* LicenseActivationSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LicenseActivationSheet.swift; sourceTree = ""; }; + 5A87EEB72F7F88F200D028D0 /* LicenseSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LicenseSettingsView.swift; sourceTree = ""; }; + 5A87EEB82F7F88F200D028D0 /* LinkedFoldersSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkedFoldersSection.swift; sourceTree = ""; }; + 5A87EEB92F7F88F200D028D0 /* PluginsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginsSettingsView.swift; sourceTree = ""; }; + 5A87EEBA2F7F88F200D028D0 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + 5A87EEBB2F7F88F200D028D0 /* ShortcutRecorderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutRecorderView.swift; sourceTree = ""; }; + 5A87EEBC2F7F88F200D028D0 /* SyncSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncSettingsView.swift; sourceTree = ""; }; + 5A87EEBD2F7F88F200D028D0 /* ThemePreviewCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemePreviewCard.swift; sourceTree = ""; }; + 5A87EEBF2F7F88F200D028D0 /* DoubleClickDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoubleClickDetector.swift; sourceTree = ""; }; + 5A87EEC02F7F88F200D028D0 /* FavoriteEditDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteEditDialog.swift; sourceTree = ""; }; + 5A87EEC12F7F88F200D028D0 /* FavoriteRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteRowView.swift; sourceTree = ""; }; + 5A87EEC22F7F88F200D028D0 /* FavoritesTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesTabView.swift; sourceTree = ""; }; + 5A87EEC32F7F88F200D028D0 /* RedisKeyTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedisKeyTreeView.swift; sourceTree = ""; }; + 5A87EEC42F7F88F200D028D0 /* SidebarContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarContextMenu.swift; sourceTree = ""; }; + 5A87EEC52F7F88F200D028D0 /* SidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarView.swift; sourceTree = ""; }; + 5A87EEC62F7F88F200D028D0 /* TableOperationDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableOperationDialog.swift; sourceTree = ""; }; + 5A87EEC72F7F88F200D028D0 /* TableRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableRowView.swift; sourceTree = ""; }; + 5A87EEC92F7F88F200D028D0 /* ClickHousePartsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClickHousePartsView.swift; sourceTree = ""; }; + 5A87EECA2F7F88F200D028D0 /* CreateTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateTableView.swift; sourceTree = ""; }; + 5A87EECB2F7F88F200D028D0 /* DDLTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DDLTextView.swift; sourceTree = ""; }; + 5A87EECC2F7F88F200D028D0 /* SchemaPreviewSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchemaPreviewSheet.swift; sourceTree = ""; }; + 5A87EECD2F7F88F200D028D0 /* StructureColumnReorderHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StructureColumnReorderHandler.swift; sourceTree = ""; }; + 5A87EECE2F7F88F200D028D0 /* StructureRowProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StructureRowProvider.swift; sourceTree = ""; }; + 5A87EECF2F7F88F200D028D0 /* StructureViewActionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StructureViewActionHandler.swift; sourceTree = ""; }; + 5A87EED02F7F88F200D028D0 /* TableStructureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableStructureView.swift; sourceTree = ""; }; + 5A87EED12F7F88F200D028D0 /* TypePickerContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypePickerContentView.swift; sourceTree = ""; }; + 5A87EED32F7F88F200D028D0 /* ConnectionStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionStatusView.swift; sourceTree = ""; }; + 5A87EED42F7F88F200D028D0 /* ConnectionSwitcherPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionSwitcherPopover.swift; sourceTree = ""; }; + 5A87EED52F7F88F200D028D0 /* ExecutionIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExecutionIndicatorView.swift; sourceTree = ""; }; + 5A87EED62F7F88F200D028D0 /* SafeModeBadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafeModeBadgeView.swift; sourceTree = ""; }; + 5A87EED72F7F88F200D028D0 /* TableProToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableProToolbarView.swift; sourceTree = ""; }; + 5A87EED82F7F88F200D028D0 /* TagBadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagBadgeView.swift; sourceTree = ""; }; + 5A87EEDB2F7F88F200D028D0 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 5A87EEDC2F7F88F200D028D0 /* AppDelegate+ConnectionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+ConnectionHandler.swift"; sourceTree = ""; }; + 5A87EEDD2F7F88F200D028D0 /* AppDelegate+FileOpen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+FileOpen.swift"; sourceTree = ""; }; + 5A87EEDE2F7F88F200D028D0 /* AppDelegate+WindowConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+WindowConfig.swift"; sourceTree = ""; }; + 5A87EEDF2F7F88F200D028D0 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 5A87EEE02F7F88F200D028D0 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 5A87EEE12F7F88F200D028D0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 5A87EEE22F7F88F200D028D0 /* TablePro.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TablePro.entitlements; sourceTree = ""; }; + 5A87EEE32F7F88F200D028D0 /* TableProApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableProApp.swift; sourceTree = ""; }; + 5A87EEE52F7F891F00D028D0 /* CloudKitSyncEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitSyncEngine.swift; sourceTree = ""; }; + 5A87EEE62F7F891F00D028D0 /* SyncConflict.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncConflict.swift; sourceTree = ""; }; + 5A87EEE72F7F891F00D028D0 /* SyncError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncError.swift; sourceTree = ""; }; + 5A87EEE82F7F891F00D028D0 /* SyncMetadataStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncMetadataStorage.swift; sourceTree = ""; }; + 5A87EEE92F7F891F00D028D0 /* SyncRecordMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncRecordMapper.swift; sourceTree = ""; }; + 5A87EEEA2F7F891F00D028D0 /* SyncRecordType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncRecordType.swift; sourceTree = ""; }; + 5AA313342F7EA5B4008EBA97 /* LibPQ.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = LibPQ.xcframework; path = ../Libs/ios/LibPQ.xcframework; sourceTree = ""; }; + 5AA313352F7EA5B4008EBA97 /* Hiredis.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = Hiredis.xcframework; path = ../Libs/ios/Hiredis.xcframework; sourceTree = ""; }; + 5AA313362F7EA5B4008EBA97 /* OpenSSL-Crypto.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = "OpenSSL-Crypto.xcframework"; path = "../Libs/ios/OpenSSL-Crypto.xcframework"; sourceTree = ""; }; + 5AA313372F7EA5B4008EBA97 /* MariaDB.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = MariaDB.xcframework; path = ../Libs/ios/MariaDB.xcframework; sourceTree = ""; }; + 5AA313382F7EA5B4008EBA97 /* Hiredis-SSL.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = "Hiredis-SSL.xcframework"; path = "../Libs/ios/Hiredis-SSL.xcframework"; sourceTree = ""; }; + 5AA313392F7EA5B4008EBA97 /* OpenSSL-SSL.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = "OpenSSL-SSL.xcframework"; path = "../Libs/ios/OpenSSL-SSL.xcframework"; sourceTree = ""; }; + 5AA313532F7EC188008EBA97 /* LibSSH2.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = LibSSH2.xcframework; path = ../Libs/ios/LibSSH2.xcframework; sourceTree = ""; }; 5AB9F3D92F7C1C12001F3337 /* TableProMobile.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TableProMobile.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -30,9 +503,17 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 5AA3133A2F7EA5B4008EBA97 /* LibPQ.xcframework in Frameworks */, 5AB9F3EF2F7C1D03001F3337 /* TableProQuery in Frameworks */, + 5AA313402F7EA5B4008EBA97 /* MariaDB.xcframework in Frameworks */, + 5AA3133C2F7EA5B4008EBA97 /* Hiredis.xcframework in Frameworks */, + 5AA313542F7EC188008EBA97 /* LibSSH2.xcframework in Frameworks */, + 5AA313422F7EA5B4008EBA97 /* Hiredis-SSL.xcframework in Frameworks */, 5AB9F3E92F7C1D03001F3337 /* TableProDatabase in Frameworks */, + 5AA3133E2F7EA5B4008EBA97 /* OpenSSL-Crypto.xcframework in Frameworks */, + 5AA313442F7EA5B4008EBA97 /* OpenSSL-SSL.xcframework in Frameworks */, 5AB9F3ED2F7C1D03001F3337 /* TableProPluginKit in Frameworks */, + 5A87EEED2F7F893000D028D0 /* TableProSync in Frameworks */, 5AB9F3EB2F7C1D03001F3337 /* TableProModels in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -40,10 +521,1037 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 5A87ECE72F7F88F200D028D0 /* AI */ = { + isa = PBXGroup; + children = ( + 5A87ECDD2F7F88F200D028D0 /* AIPromptTemplates.swift */, + 5A87ECDE2F7F88F200D028D0 /* AIPromptTemplates+InlineSuggest.swift */, + 5A87ECDF2F7F88F200D028D0 /* AIProvider.swift */, + 5A87ECE02F7F88F200D028D0 /* AIProviderFactory.swift */, + 5A87ECE12F7F88F200D028D0 /* AISchemaContext.swift */, + 5A87ECE22F7F88F200D028D0 /* AnthropicProvider.swift */, + 5A87ECE32F7F88F200D028D0 /* GeminiProvider.swift */, + 5A87ECE42F7F88F200D028D0 /* InlineSuggestionManager.swift */, + 5A87ECE52F7F88F200D028D0 /* OllamaDetector.swift */, + 5A87ECE62F7F88F200D028D0 /* OpenAICompatibleProvider.swift */, + ); + path = AI; + sourceTree = ""; + }; + 5A87ECEE2F7F88F200D028D0 /* Autocomplete */ = { + isa = PBXGroup; + children = ( + 5A87ECE82F7F88F200D028D0 /* CompletionEngine.swift */, + 5A87ECE92F7F88F200D028D0 /* SQLCompletionItem.swift */, + 5A87ECEA2F7F88F200D028D0 /* SQLCompletionProvider.swift */, + 5A87ECEB2F7F88F200D028D0 /* SQLContextAnalyzer.swift */, + 5A87ECEC2F7F88F200D028D0 /* SQLKeywords.swift */, + 5A87ECED2F7F88F200D028D0 /* SQLSchemaProvider.swift */, + ); + path = Autocomplete; + sourceTree = ""; + }; + 5A87ECF42F7F88F200D028D0 /* ChangeTracking */ = { + isa = PBXGroup; + children = ( + 5A87ECEF2F7F88F200D028D0 /* AnyChangeManager.swift */, + 5A87ECF02F7F88F200D028D0 /* DataChangeManager.swift */, + 5A87ECF12F7F88F200D028D0 /* DataChangeModels.swift */, + 5A87ECF22F7F88F200D028D0 /* DataChangeUndoManager.swift */, + 5A87ECF32F7F88F200D028D0 /* SQLStatementGenerator.swift */, + ); + path = ChangeTracking; + sourceTree = ""; + }; + 5A87ECFA2F7F88F200D028D0 /* Database */ = { + isa = PBXGroup; + children = ( + 5A87ECF52F7F88F200D028D0 /* ConnectionHealthMonitor.swift */, + 5A87ECF62F7F88F200D028D0 /* DatabaseDriver.swift */, + 5A87ECF72F7F88F200D028D0 /* DatabaseManager.swift */, + 5A87ECF82F7F88F200D028D0 /* FilterSQLGenerator.swift */, + 5A87ECF92F7F88F200D028D0 /* SQLEscaping.swift */, + ); + path = Database; + sourceTree = ""; + }; + 5A87ECFE2F7F88F200D028D0 /* KeyboardHandling */ = { + isa = PBXGroup; + children = ( + 5A87ECFB2F7F88F200D028D0 /* KeyCode.swift */, + 5A87ECFC2F7F88F200D028D0 /* PasteboardActionRouter.swift */, + 5A87ECFD2F7F88F200D028D0 /* ResponderChainActions.swift */, + ); + path = KeyboardHandling; + sourceTree = ""; + }; + 5A87ED042F7F88F200D028D0 /* Registry */ = { + isa = PBXGroup; + children = ( + 5A87ECFF2F7F88F200D028D0 /* DownloadCountService.swift */, + 5A87ED002F7F88F200D028D0 /* PluginInstallTracker.swift */, + 5A87ED012F7F88F200D028D0 /* PluginManager+Registry.swift */, + 5A87ED022F7F88F200D028D0 /* RegistryClient.swift */, + 5A87ED032F7F88F200D028D0 /* RegistryModels.swift */, + ); + path = Registry; + sourceTree = ""; + }; + 5A87ED102F7F88F200D028D0 /* Plugins */ = { + isa = PBXGroup; + children = ( + 5A87ED042F7F88F200D028D0 /* Registry */, + 5A87ED052F7F88F200D028D0 /* ExportDataSourceAdapter.swift */, + 5A87ED062F7F88F200D028D0 /* ImportDataSinkAdapter.swift */, + 5A87ED072F7F88F200D028D0 /* PluginDriverAdapter.swift */, + 5A87ED082F7F88F200D028D0 /* PluginError.swift */, + 5A87ED092F7F88F200D028D0 /* PluginManager.swift */, + 5A87ED0A2F7F88F200D028D0 /* PluginMetadataRegistry.swift */, + 5A87ED0B2F7F88F200D028D0 /* PluginMetadataRegistry+CloudDefaults.swift */, + 5A87ED0C2F7F88F200D028D0 /* PluginMetadataRegistry+RegistryDefaults.swift */, + 5A87ED0D2F7F88F200D028D0 /* PluginModels.swift */, + 5A87ED0E2F7F88F200D028D0 /* QueryResultExportDataSource.swift */, + 5A87ED0F2F7F88F200D028D0 /* SqlFileImportSource.swift */, + ); + path = Plugins; + sourceTree = ""; + }; + 5A87ED142F7F88F200D028D0 /* SchemaTracking */ = { + isa = PBXGroup; + children = ( + 5A87ED112F7F88F200D028D0 /* SchemaStatementGenerator.swift */, + 5A87ED122F7F88F200D028D0 /* StructureChangeManager.swift */, + 5A87ED132F7F88F200D028D0 /* StructureUndoManager.swift */, + ); + path = SchemaTracking; + sourceTree = ""; + }; + 5A87ED1B2F7F88F200D028D0 /* Export */ = { + isa = PBXGroup; + children = ( + 5A87ED152F7F88F200D028D0 /* ConnectionExportCrypto.swift */, + 5A87ED162F7F88F200D028D0 /* ConnectionExportService.swift */, + 5A87ED172F7F88F200D028D0 /* ExportService.swift */, + 5A87ED182F7F88F200D028D0 /* ImportService.swift */, + 5A87ED192F7F88F200D028D0 /* LinkedFolderWatcher.swift */, + 5A87ED1A2F7F88F200D028D0 /* ProgressUpdateCoalescer.swift */, + ); + path = Export; + sourceTree = ""; + }; + 5A87ED212F7F88F200D028D0 /* Formatting */ = { + isa = PBXGroup; + children = ( + 5A87ED1C2F7F88F200D028D0 /* BlobFormattingService.swift */, + 5A87ED1D2F7F88F200D028D0 /* CellDisplayFormatter.swift */, + 5A87ED1E2F7F88F200D028D0 /* DateFormattingService.swift */, + 5A87ED1F2F7F88F200D028D0 /* SQLFormatterService.swift */, + 5A87ED202F7F88F200D028D0 /* SQLFormatterTypes.swift */, + ); + path = Formatting; + sourceTree = ""; + }; + 5A87ED302F7F88F200D028D0 /* Infrastructure */ = { + isa = PBXGroup; + children = ( + 5A87ED222F7F88F200D028D0 /* AnalyticsService.swift */, + 5A87ED232F7F88F200D028D0 /* AppNotifications.swift */, + 5A87ED242F7F88F200D028D0 /* ClipboardService.swift */, + 5A87ED252F7F88F200D028D0 /* DeeplinkHandler.swift */, + 5A87ED262F7F88F200D028D0 /* PreConnectHookRunner.swift */, + 5A87ED272F7F88F200D028D0 /* SafeModeGuard.swift */, + 5A87ED282F7F88F200D028D0 /* SessionStateFactory.swift */, + 5A87ED292F7F88F200D028D0 /* SettingsNotifications.swift */, + 5A87ED2A2F7F88F200D028D0 /* SettingsValidation.swift */, + 5A87ED2B2F7F88F200D028D0 /* SQLFileService.swift */, + 5A87ED2C2F7F88F200D028D0 /* TabPersistenceCoordinator.swift */, + 5A87ED2D2F7F88F200D028D0 /* UpdaterBridge.swift */, + 5A87ED2E2F7F88F200D028D0 /* WindowLifecycleMonitor.swift */, + 5A87ED2F2F7F88F200D028D0 /* WindowOpener.swift */, + ); + path = Infrastructure; + sourceTree = ""; + }; + 5A87ED362F7F88F200D028D0 /* Licensing */ = { + isa = PBXGroup; + children = ( + 5A87ED312F7F88F200D028D0 /* LicenseAPIClient.swift */, + 5A87ED322F7F88F200D028D0 /* LicenseConstants.swift */, + 5A87ED332F7F88F200D028D0 /* LicenseManager.swift */, + 5A87ED342F7F88F200D028D0 /* LicenseManager+Pro.swift */, + 5A87ED352F7F88F200D028D0 /* LicenseSignatureVerifier.swift */, + ); + path = Licensing; + sourceTree = ""; + }; + 5A87ED3E2F7F88F200D028D0 /* Query */ = { + isa = PBXGroup; + children = ( + 5A87ED372F7F88F200D028D0 /* ColumnExclusionPolicy.swift */, + 5A87ED382F7F88F200D028D0 /* RowOperationsManager.swift */, + 5A87ED392F7F88F200D028D0 /* RowParser.swift */, + 5A87ED3A2F7F88F200D028D0 /* SchemaProviderRegistry.swift */, + 5A87ED3B2F7F88F200D028D0 /* SQLDialectProvider.swift */, + 5A87ED3C2F7F88F200D028D0 /* SQLFunctionProvider.swift */, + 5A87ED3D2F7F88F200D028D0 /* TableQueryBuilder.swift */, + ); + path = Query; + sourceTree = ""; + }; + 5A87ED412F7F88F200D028D0 /* Services */ = { + isa = PBXGroup; + children = ( + 5A87ED1B2F7F88F200D028D0 /* Export */, + 5A87ED212F7F88F200D028D0 /* Formatting */, + 5A87ED302F7F88F200D028D0 /* Infrastructure */, + 5A87ED362F7F88F200D028D0 /* Licensing */, + 5A87ED3E2F7F88F200D028D0 /* Query */, + 5A87ED3F2F7F88F200D028D0 /* ColumnType.swift */, + 5A87ED402F7F88F200D028D0 /* ColumnTypeClassifier.swift */, + ); + path = Services; + sourceTree = ""; + }; + 5A87ED4A2F7F88F200D028D0 /* Auth */ = { + isa = PBXGroup; + children = ( + 5A87ED422F7F88F200D028D0 /* AgentAuthenticator.swift */, + 5A87ED432F7F88F200D028D0 /* CompositeAuthenticator.swift */, + 5A87ED442F7F88F200D028D0 /* KeyboardInteractiveAuthenticator.swift */, + 5A87ED452F7F88F200D028D0 /* PasswordAuthenticator.swift */, + 5A87ED462F7F88F200D028D0 /* PromptTOTPProvider.swift */, + 5A87ED472F7F88F200D028D0 /* PublicKeyAuthenticator.swift */, + 5A87ED482F7F88F200D028D0 /* SSHAuthenticator.swift */, + 5A87ED492F7F88F200D028D0 /* TOTPProvider.swift */, + ); + path = Auth; + sourceTree = ""; + }; + 5A87ED4F2F7F88F200D028D0 /* include */ = { + isa = PBXGroup; + children = ( + 5A87ED4B2F7F88F200D028D0 /* .gitkeep */, + 5A87ED4C2F7F88F200D028D0 /* libssh2.h */, + 5A87ED4D2F7F88F200D028D0 /* libssh2_publickey.h */, + 5A87ED4E2F7F88F200D028D0 /* libssh2_sftp.h */, + ); + path = include; + sourceTree = ""; + }; + 5A87ED522F7F88F200D028D0 /* CLibSSH2 */ = { + isa = PBXGroup; + children = ( + 5A87ED4F2F7F88F200D028D0 /* include */, + 5A87ED502F7F88F200D028D0 /* CLibSSH2.h */, + 5A87ED512F7F88F200D028D0 /* module.modulemap */, + ); + path = CLibSSH2; + sourceTree = ""; + }; + 5A87ED552F7F88F200D028D0 /* TOTP */ = { + isa = PBXGroup; + children = ( + 5A87ED532F7F88F200D028D0 /* Base32.swift */, + 5A87ED542F7F88F200D028D0 /* TOTPGenerator.swift */, + ); + path = TOTP; + sourceTree = ""; + }; + 5A87ED5D2F7F88F200D028D0 /* SSH */ = { + isa = PBXGroup; + children = ( + 5A87ED4A2F7F88F200D028D0 /* Auth */, + 5A87ED522F7F88F200D028D0 /* CLibSSH2 */, + 5A87ED552F7F88F200D028D0 /* TOTP */, + 5A87ED562F7F88F200D028D0 /* HostKeyStore.swift */, + 5A87ED572F7F88F200D028D0 /* HostKeyVerifier.swift */, + 5A87ED582F7F88F200D028D0 /* LibSSH2Tunnel.swift */, + 5A87ED592F7F88F200D028D0 /* LibSSH2TunnelFactory.swift */, + 5A87ED5A2F7F88F200D028D0 /* SSHConfigParser.swift */, + 5A87ED5B2F7F88F200D028D0 /* SSHPathUtilities.swift */, + 5A87ED5C2F7F88F200D028D0 /* SSHTunnelManager.swift */, + ); + path = SSH; + sourceTree = ""; + }; + 5A87ED702F7F88F200D028D0 /* Storage */ = { + isa = PBXGroup; + children = ( + 5A87ED5E2F7F88F200D028D0 /* AIChatStorage.swift */, + 5A87ED5F2F7F88F200D028D0 /* AIKeyStorage.swift */, + 5A87ED602F7F88F200D028D0 /* AppSettingsManager.swift */, + 5A87ED612F7F88F200D028D0 /* AppSettingsStorage.swift */, + 5A87ED622F7F88F200D028D0 /* ColumnLayoutStorage.swift */, + 5A87ED632F7F88F200D028D0 /* ConnectionStorage.swift */, + 5A87ED642F7F88F200D028D0 /* FilterSettingsStorage.swift */, + 5A87ED652F7F88F200D028D0 /* GroupStorage.swift */, + 5A87ED662F7F88F200D028D0 /* KeychainHelper.swift */, + 5A87ED672F7F88F200D028D0 /* LicenseStorage.swift */, + 5A87ED682F7F88F200D028D0 /* LinkedFolderStorage.swift */, + 5A87ED692F7F88F200D028D0 /* QueryHistoryManager.swift */, + 5A87ED6A2F7F88F200D028D0 /* QueryHistoryStorage.swift */, + 5A87ED6B2F7F88F200D028D0 /* SQLFavoriteManager.swift */, + 5A87ED6C2F7F88F200D028D0 /* SQLFavoriteStorage.swift */, + 5A87ED6D2F7F88F200D028D0 /* SSHProfileStorage.swift */, + 5A87ED6E2F7F88F200D028D0 /* TabDiskActor.swift */, + 5A87ED6F2F7F88F200D028D0 /* TagStorage.swift */, + ); + path = Storage; + sourceTree = ""; + }; + 5A87ED792F7F88F200D028D0 /* Sync */ = { + isa = PBXGroup; + children = ( + 5A87ED712F7F88F200D028D0 /* CloudKitSyncEngine.swift */, + 5A87ED722F7F88F200D028D0 /* ConflictResolver.swift */, + 5A87ED732F7F88F200D028D0 /* SyncChangeTracker.swift */, + 5A87ED742F7F88F200D028D0 /* SyncCoordinator.swift */, + 5A87ED752F7F88F200D028D0 /* SyncError.swift */, + 5A87ED762F7F88F200D028D0 /* SyncMetadataStorage.swift */, + 5A87ED772F7F88F200D028D0 /* SyncRecordMapper.swift */, + 5A87ED782F7F88F200D028D0 /* SyncStatus.swift */, + ); + path = Sync; + sourceTree = ""; + }; + 5A87ED7F2F7F88F200D028D0 /* Connection */ = { + isa = PBXGroup; + children = ( + 5A87ED7A2F7F88F200D028D0 /* ConnectionURLFormatter.swift */, + 5A87ED7B2F7F88F200D028D0 /* ConnectionURLParser.swift */, + 5A87ED7C2F7F88F200D028D0 /* EnvVarResolver.swift */, + 5A87ED7D2F7F88F200D028D0 /* ExponentialBackoff.swift */, + 5A87ED7E2F7F88F200D028D0 /* PgpassReader.swift */, + ); + path = Connection; + sourceTree = ""; + }; + 5A87ED812F7F88F200D028D0 /* File */ = { + isa = PBXGroup; + children = ( + 5A87ED802F7F88F200D028D0 /* FileDecompressor.swift */, + ); + path = File; + sourceTree = ""; + }; + 5A87ED892F7F88F200D028D0 /* SQL */ = { + isa = PBXGroup; + children = ( + 5A87ED822F7F88F200D028D0 /* DialectQuoteHelper.swift */, + 5A87ED832F7F88F200D028D0 /* JsonRowConverter.swift */, + 5A87ED842F7F88F200D028D0 /* RowSortComparator.swift */, + 5A87ED852F7F88F200D028D0 /* SQLFileParser.swift */, + 5A87ED862F7F88F200D028D0 /* SQLParameterInliner.swift */, + 5A87ED872F7F88F200D028D0 /* SQLRowToStatementConverter.swift */, + 5A87ED882F7F88F200D028D0 /* SQLStatementScanner.swift */, + ); + path = SQL; + sourceTree = ""; + }; + 5A87ED8D2F7F88F200D028D0 /* UI */ = { + isa = PBXGroup; + children = ( + 5A87ED8A2F7F88F200D028D0 /* AlertHelper.swift */, + 5A87ED8B2F7F88F200D028D0 /* FuzzyMatcher.swift */, + 5A87ED8C2F7F88F200D028D0 /* PasswordPromptHelper.swift */, + ); + path = UI; + sourceTree = ""; + }; + 5A87ED8F2F7F88F200D028D0 /* Utilities */ = { + isa = PBXGroup; + children = ( + 5A87ED7F2F7F88F200D028D0 /* Connection */, + 5A87ED812F7F88F200D028D0 /* File */, + 5A87ED892F7F88F200D028D0 /* SQL */, + 5A87ED8D2F7F88F200D028D0 /* UI */, + 5A87ED8E2F7F88F200D028D0 /* MemoryPressureAdvisor.swift */, + ); + path = Utilities; + sourceTree = ""; + }; + 5A87ED982F7F88F200D028D0 /* Vim */ = { + isa = PBXGroup; + children = ( + 5A87ED902F7F88F200D028D0 /* VimCommandLineHandler.swift */, + 5A87ED912F7F88F200D028D0 /* VimCursorManager.swift */, + 5A87ED922F7F88F200D028D0 /* VimEngine.swift */, + 5A87ED932F7F88F200D028D0 /* VimKeyInterceptor.swift */, + 5A87ED942F7F88F200D028D0 /* VimMode.swift */, + 5A87ED952F7F88F200D028D0 /* VimRegister.swift */, + 5A87ED962F7F88F200D028D0 /* VimTextBuffer.swift */, + 5A87ED972F7F88F200D028D0 /* VimTextBufferAdapter.swift */, + ); + path = Vim; + sourceTree = ""; + }; + 5A87ED992F7F88F200D028D0 /* Core */ = { + isa = PBXGroup; + children = ( + 5A87ECE72F7F88F200D028D0 /* AI */, + 5A87ECEE2F7F88F200D028D0 /* Autocomplete */, + 5A87ECF42F7F88F200D028D0 /* ChangeTracking */, + 5A87ECFA2F7F88F200D028D0 /* Database */, + 5A87ECFE2F7F88F200D028D0 /* KeyboardHandling */, + 5A87ED102F7F88F200D028D0 /* Plugins */, + 5A87ED142F7F88F200D028D0 /* SchemaTracking */, + 5A87ED412F7F88F200D028D0 /* Services */, + 5A87ED5D2F7F88F200D028D0 /* SSH */, + 5A87ED702F7F88F200D028D0 /* Storage */, + 5A87ED792F7F88F200D028D0 /* Sync */, + 5A87ED8F2F7F88F200D028D0 /* Utilities */, + 5A87ED982F7F88F200D028D0 /* Vim */, + ); + path = Core; + sourceTree = ""; + }; + 5A87EDA62F7F88F200D028D0 /* Extensions */ = { + isa = PBXGroup; + children = ( + 5A87ED9A2F7F88F200D028D0 /* Bundle+AppInfo.swift */, + 5A87ED9B2F7F88F200D028D0 /* Color+Hex.swift */, + 5A87ED9C2F7F88F200D028D0 /* Date+Extensions.swift */, + 5A87ED9D2F7F88F200D028D0 /* EditorLanguage+TreeSitter.swift */, + 5A87ED9E2F7F88F200D028D0 /* NSApplication+WindowManagement.swift */, + 5A87ED9F2F7F88F200D028D0 /* NSView+Focus.swift */, + 5A87EDA02F7F88F200D028D0 /* NSViewController+SwiftUI.swift */, + 5A87EDA12F7F88F200D028D0 /* String+HexDump.swift */, + 5A87EDA22F7F88F200D028D0 /* String+JSON.swift */, + 5A87EDA32F7F88F200D028D0 /* String+SHA256.swift */, + 5A87EDA42F7F88F200D028D0 /* UserDefaults+RecentDatabases.swift */, + 5A87EDA52F7F88F200D028D0 /* View+OptionalShortcut.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + 5A87EDA92F7F88F200D028D0 /* AI */ = { + isa = PBXGroup; + children = ( + 5A87EDA72F7F88F200D028D0 /* AIConversation.swift */, + 5A87EDA82F7F88F200D028D0 /* AIModels.swift */, + ); + path = AI; + sourceTree = ""; + }; + 5A87EDAD2F7F88F200D028D0 /* ClickHouse */ = { + isa = PBXGroup; + children = ( + 5A87EDAA2F7F88F200D028D0 /* ClickHouseExplainVariant.swift */, + 5A87EDAB2F7F88F200D028D0 /* ClickHousePartInfo.swift */, + 5A87EDAC2F7F88F200D028D0 /* ClickHouseQueryProgress.swift */, + ); + path = ClickHouse; + sourceTree = ""; + }; + 5A87EDB92F7F88F200D028D0 /* Connection */ = { + isa = PBXGroup; + children = ( + 5A87EDAE2F7F88F200D028D0 /* ConnectionExport.swift */, + 5A87EDAF2F7F88F200D028D0 /* ConnectionGroup.swift */, + 5A87EDB02F7F88F200D028D0 /* ConnectionGroupTree.swift */, + 5A87EDB12F7F88F200D028D0 /* ConnectionSession.swift */, + 5A87EDB22F7F88F200D028D0 /* ConnectionTag.swift */, + 5A87EDB32F7F88F200D028D0 /* ConnectionToolbarState.swift */, + 5A87EDB42F7F88F200D028D0 /* DatabaseConnection.swift */, + 5A87EDB52F7F88F200D028D0 /* DatabaseConnection+SSH.swift */, + 5A87EDB62F7F88F200D028D0 /* SafeModeLevel.swift */, + 5A87EDB72F7F88F200D028D0 /* SSHProfile.swift */, + 5A87EDB82F7F88F200D028D0 /* TOTPConfiguration.swift */, + ); + path = Connection; + sourceTree = ""; + }; + 5A87EDBF2F7F88F200D028D0 /* Database */ = { + isa = PBXGroup; + children = ( + 5A87EDBA2F7F88F200D028D0 /* DatabaseMetadata.swift */, + 5A87EDBB2F7F88F200D028D0 /* TableFilter.swift */, + 5A87EDBC2F7F88F200D028D0 /* TableMetadata.swift */, + 5A87EDBD2F7F88F200D028D0 /* TableOperationOptions.swift */, + 5A87EDBE2F7F88F200D028D0 /* TableSchema.swift */, + ); + path = Database; + sourceTree = ""; + }; + 5A87EDC22F7F88F200D028D0 /* Export */ = { + isa = PBXGroup; + children = ( + 5A87EDC02F7F88F200D028D0 /* ExportModels.swift */, + 5A87EDC12F7F88F200D028D0 /* ImportModels.swift */, + ); + path = Export; + sourceTree = ""; + }; + 5A87EDCC2F7F88F200D028D0 /* Query */ = { + isa = PBXGroup; + children = ( + 5A87EDC32F7F88F200D028D0 /* EditorTabPayload.swift */, + 5A87EDC42F7F88F200D028D0 /* ParsedRow.swift */, + 5A87EDC52F7F88F200D028D0 /* QueryHistoryEntry.swift */, + 5A87EDC62F7F88F200D028D0 /* QueryResult.swift */, + 5A87EDC72F7F88F200D028D0 /* QueryTab.swift */, + 5A87EDC82F7F88F200D028D0 /* ResultSet.swift */, + 5A87EDC92F7F88F200D028D0 /* RowProvider.swift */, + 5A87EDCA2F7F88F200D028D0 /* SQLFavorite.swift */, + 5A87EDCB2F7F88F200D028D0 /* SQLFavoriteFolder.swift */, + ); + path = Query; + sourceTree = ""; + }; + 5A87EDD42F7F88F200D028D0 /* Schema */ = { + isa = PBXGroup; + children = ( + 5A87EDCD2F7F88F200D028D0 /* ColumnDefinition.swift */, + 5A87EDCE2F7F88F200D028D0 /* CreateTableOptions.swift */, + 5A87EDCF2F7F88F200D028D0 /* ForeignKeyDefinition.swift */, + 5A87EDD02F7F88F200D028D0 /* IndexDefinition.swift */, + 5A87EDD12F7F88F200D028D0 /* SchemaChange.swift */, + 5A87EDD22F7F88F200D028D0 /* SchemaChange+Undo.swift */, + 5A87EDD32F7F88F200D028D0 /* StructureTab.swift */, + ); + path = Schema; + sourceTree = ""; + }; + 5A87EDD92F7F88F200D028D0 /* Settings */ = { + isa = PBXGroup; + children = ( + 5A87EDD52F7F88F200D028D0 /* AppSettings.swift */, + 5A87EDD62F7F88F200D028D0 /* License.swift */, + 5A87EDD72F7F88F200D028D0 /* ProFeature.swift */, + 5A87EDD82F7F88F200D028D0 /* SyncSettings.swift */, + ); + path = Settings; + sourceTree = ""; + }; + 5A87EDE52F7F88F200D028D0 /* UI */ = { + isa = PBXGroup; + children = ( + 5A87EDDA2F7F88F200D028D0 /* ColumnVisibilityManager.swift */, + 5A87EDDB2F7F88F200D028D0 /* FilterPreset.swift */, + 5A87EDDC2F7F88F200D028D0 /* FilterState.swift */, + 5A87EDDD2F7F88F200D028D0 /* InspectorContext.swift */, + 5A87EDDE2F7F88F200D028D0 /* KeyboardShortcutModels.swift */, + 5A87EDDF2F7F88F200D028D0 /* MultiRowEditState.swift */, + 5A87EDE02F7F88F200D028D0 /* QuickSwitcherItem.swift */, + 5A87EDE12F7F88F200D028D0 /* RedisKeyNode.swift */, + 5A87EDE22F7F88F200D028D0 /* RightPanelState.swift */, + 5A87EDE32F7F88F200D028D0 /* RightPanelTab.swift */, + 5A87EDE42F7F88F200D028D0 /* SharedSidebarState.swift */, + ); + path = UI; + sourceTree = ""; + }; + 5A87EDE62F7F88F200D028D0 /* Models */ = { + isa = PBXGroup; + children = ( + 5A87EDA92F7F88F200D028D0 /* AI */, + 5A87EDAD2F7F88F200D028D0 /* ClickHouse */, + 5A87EDB92F7F88F200D028D0 /* Connection */, + 5A87EDBF2F7F88F200D028D0 /* Database */, + 5A87EDC22F7F88F200D028D0 /* Export */, + 5A87EDCC2F7F88F200D028D0 /* Query */, + 5A87EDD42F7F88F200D028D0 /* Schema */, + 5A87EDD92F7F88F200D028D0 /* Settings */, + 5A87EDE52F7F88F200D028D0 /* UI */, + ); + path = Models; + sourceTree = ""; + }; + 5A87EDEB2F7F88F200D028D0 /* Themes */ = { + isa = PBXGroup; + children = ( + 5A87EDE72F7F88F200D028D0 /* tablepro.default-dark.json */, + 5A87EDE82F7F88F200D028D0 /* tablepro.default-light.json */, + 5A87EDE92F7F88F200D028D0 /* tablepro.dracula.json */, + 5A87EDEA2F7F88F200D028D0 /* tablepro.nord.json */, + ); + path = Themes; + sourceTree = ""; + }; + 5A87EDEF2F7F88F200D028D0 /* Resources */ = { + isa = PBXGroup; + children = ( + 5A87EDEB2F7F88F200D028D0 /* Themes */, + 5A87EDEC2F7F88F200D028D0 /* license_public.pem */, + 5A87EDED2F7F88F200D028D0 /* Localizable.xcstrings */, + 5A87EDEE2F7F88F200D028D0 /* SQLDocument.icns */, + ); + path = Resources; + sourceTree = ""; + }; + 5A87EDF72F7F88F200D028D0 /* Theme */ = { + isa = PBXGroup; + children = ( + 5A87EDF02F7F88F200D028D0 /* HexColor.swift */, + 5A87EDF12F7F88F200D028D0 /* RegistryThemeMeta.swift */, + 5A87EDF22F7F88F200D028D0 /* ResolvedThemeColors.swift */, + 5A87EDF32F7F88F200D028D0 /* ThemeDefinition.swift */, + 5A87EDF42F7F88F200D028D0 /* ThemeEngine.swift */, + 5A87EDF52F7F88F200D028D0 /* ThemeRegistryInstaller.swift */, + 5A87EDF62F7F88F200D028D0 /* ThemeStorage.swift */, + ); + path = Theme; + sourceTree = ""; + }; + 5A87EDFF2F7F88F200D028D0 /* ViewModels */ = { + isa = PBXGroup; + children = ( + 5A87EDF82F7F88F200D028D0 /* AIChatViewModel.swift */, + 5A87EDF92F7F88F200D028D0 /* DatabaseSwitcherViewModel.swift */, + 5A87EDFA2F7F88F200D028D0 /* FavoritesSidebarViewModel.swift */, + 5A87EDFB2F7F88F200D028D0 /* QuickSwitcherViewModel.swift */, + 5A87EDFC2F7F88F200D028D0 /* RedisKeyTreeViewModel.swift */, + 5A87EDFD2F7F88F200D028D0 /* SidebarViewModel.swift */, + 5A87EDFE2F7F88F200D028D0 /* WelcomeViewModel.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; + 5A87EE022F7F88F200D028D0 /* About */ = { + isa = PBXGroup; + children = ( + 5A87EE002F7F88F200D028D0 /* AboutView.swift */, + 5A87EE012F7F88F200D028D0 /* AboutWindowController.swift */, + ); + path = About; + sourceTree = ""; + }; + 5A87EE062F7F88F200D028D0 /* AIChat */ = { + isa = PBXGroup; + children = ( + 5A87EE032F7F88F200D028D0 /* AIChatCodeBlockView.swift */, + 5A87EE042F7F88F200D028D0 /* AIChatMessageView.swift */, + 5A87EE052F7F88F200D028D0 /* AIChatPanelView.swift */, + ); + path = AIChat; + sourceTree = ""; + }; + 5A87EE122F7F88F200D028D0 /* Components */ = { + isa = PBXGroup; + children = ( + 5A87EE072F7F88F200D028D0 /* ConflictResolutionView.swift */, + 5A87EE082F7F88F200D028D0 /* EmptyStateView.swift */, + 5A87EE092F7F88F200D028D0 /* HighlightedSQLTextView.swift */, + 5A87EE0A2F7F88F200D028D0 /* PaginationControlsView.swift */, + 5A87EE0B2F7F88F200D028D0 /* PanelResizeHandle.swift */, + 5A87EE0C2F7F88F200D028D0 /* PopoverPresenter.swift */, + 5A87EE0D2F7F88F200D028D0 /* ProFeatureGate.swift */, + 5A87EE0E2F7F88F200D028D0 /* SectionHeaderView.swift */, + 5A87EE0F2F7F88F200D028D0 /* SQLReviewPopover.swift */, + 5A87EE102F7F88F200D028D0 /* SyncStatusIndicator.swift */, + 5A87EE112F7F88F200D028D0 /* WindowAccessor.swift */, + ); + path = Components; + sourceTree = ""; + }; + 5A87EE262F7F88F200D028D0 /* Connection */ = { + isa = PBXGroup; + children = ( + 5A87EE132F7F88F200D028D0 /* ConnectionAdvancedView.swift */, + 5A87EE142F7F88F200D028D0 /* ConnectionColorPicker.swift */, + 5A87EE152F7F88F200D028D0 /* ConnectionExportOptionsSheet.swift */, + 5A87EE162F7F88F200D028D0 /* ConnectionFieldRow.swift */, + 5A87EE172F7F88F200D028D0 /* ConnectionFormView.swift */, + 5A87EE182F7F88F200D028D0 /* ConnectionGroupPicker.swift */, + 5A87EE192F7F88F200D028D0 /* ConnectionImportSheet.swift */, + 5A87EE1A2F7F88F200D028D0 /* ConnectionSidebarHeader.swift */, + 5A87EE1B2F7F88F200D028D0 /* ConnectionSSHTunnelView.swift */, + 5A87EE1C2F7F88F200D028D0 /* ConnectionSSLView.swift */, + 5A87EE1D2F7F88F200D028D0 /* ConnectionTagEditor.swift */, + 5A87EE1E2F7F88F200D028D0 /* OnboardingContentView.swift */, + 5A87EE1F2F7F88F200D028D0 /* PasswordPromptToggle.swift */, + 5A87EE202F7F88F200D028D0 /* PluginInstallModifier.swift */, + 5A87EE212F7F88F200D028D0 /* SSHProfileEditorView.swift */, + 5A87EE222F7F88F200D028D0 /* WelcomeConnectionRow.swift */, + 5A87EE232F7F88F200D028D0 /* WelcomeContextMenus.swift */, + 5A87EE242F7F88F200D028D0 /* WelcomeLeftPanel.swift */, + 5A87EE252F7F88F200D028D0 /* WelcomeWindowView.swift */, + ); + path = Connection; + sourceTree = ""; + }; + 5A87EE292F7F88F200D028D0 /* DatabaseSwitcher */ = { + isa = PBXGroup; + children = ( + 5A87EE272F7F88F200D028D0 /* CreateDatabaseSheet.swift */, + 5A87EE282F7F88F200D028D0 /* DatabaseSwitcherSheet.swift */, + ); + path = DatabaseSwitcher; + sourceTree = ""; + }; + 5A87EE362F7F88F200D028D0 /* Editor */ = { + isa = PBXGroup; + children = ( + 5A87EE2A2F7F88F200D028D0 /* AIEditorContextMenu.swift */, + 5A87EE2B2F7F88F200D028D0 /* EditorEventRouter.swift */, + 5A87EE2C2F7F88F200D028D0 /* ExplainResultView.swift */, + 5A87EE2D2F7F88F200D028D0 /* HistoryPanelView.swift */, + 5A87EE2E2F7F88F200D028D0 /* QueryEditorView.swift */, + 5A87EE2F2F7F88F200D028D0 /* QuerySplitView.swift */, + 5A87EE302F7F88F200D028D0 /* QuerySuccessView.swift */, + 5A87EE312F7F88F200D028D0 /* SQLCompletionAdapter.swift */, + 5A87EE322F7F88F200D028D0 /* SQLEditorCoordinator.swift */, + 5A87EE332F7F88F200D028D0 /* SQLEditorView.swift */, + 5A87EE342F7F88F200D028D0 /* TableProEditorTheme.swift */, + 5A87EE352F7F88F200D028D0 /* VimModeIndicatorView.swift */, + ); + path = Editor; + sourceTree = ""; + }; + 5A87EE3B2F7F88F200D028D0 /* Export */ = { + isa = PBXGroup; + children = ( + 5A87EE372F7F88F200D028D0 /* ExportDialog.swift */, + 5A87EE382F7F88F200D028D0 /* ExportProgressView.swift */, + 5A87EE392F7F88F200D028D0 /* ExportSuccessView.swift */, + 5A87EE3A2F7F88F200D028D0 /* ExportTableTreeView.swift */, + ); + path = Export; + sourceTree = ""; + }; + 5A87EE422F7F88F200D028D0 /* Filter */ = { + isa = PBXGroup; + children = ( + 5A87EE3C2F7F88F200D028D0 /* CompletionTextField.swift */, + 5A87EE3D2F7F88F200D028D0 /* FilterPanelView.swift */, + 5A87EE3E2F7F88F200D028D0 /* FilterRowView.swift */, + 5A87EE3F2F7F88F200D028D0 /* FilterSettingsPopover.swift */, + 5A87EE402F7F88F200D028D0 /* MixedStateCheckbox.swift */, + 5A87EE412F7F88F200D028D0 /* SQLPreviewSheet.swift */, + ); + path = Filter; + sourceTree = ""; + }; + 5A87EE482F7F88F200D028D0 /* Import */ = { + isa = PBXGroup; + children = ( + 5A87EE432F7F88F200D028D0 /* ImportDialog.swift */, + 5A87EE442F7F88F200D028D0 /* ImportErrorView.swift */, + 5A87EE452F7F88F200D028D0 /* ImportProgressView.swift */, + 5A87EE462F7F88F200D028D0 /* ImportSuccessView.swift */, + 5A87EE472F7F88F200D028D0 /* SQLCodePreview.swift */, + ); + path = Import; + sourceTree = ""; + }; + 5A87EE4B2F7F88F200D028D0 /* Child */ = { + isa = PBXGroup; + children = ( + 5A87EE492F7F88F200D028D0 /* MainEditorContentView.swift */, + 5A87EE4A2F7F88F200D028D0 /* MainStatusBarView.swift */, + ); + path = Child; + sourceTree = ""; + }; + 5A87EE682F7F88F200D028D0 /* Extensions */ = { + isa = PBXGroup; + children = ( + 5A87EE4C2F7F88F200D028D0 /* MainContentCoordinator+Alerts.swift */, + 5A87EE4D2F7F88F200D028D0 /* MainContentCoordinator+ChangeGuard.swift */, + 5A87EE4E2F7F88F200D028D0 /* MainContentCoordinator+ClickHouse.swift */, + 5A87EE4F2F7F88F200D028D0 /* MainContentCoordinator+ColumnLayout.swift */, + 5A87EE502F7F88F200D028D0 /* MainContentCoordinator+ColumnVisibility.swift */, + 5A87EE512F7F88F200D028D0 /* MainContentCoordinator+Discard.swift */, + 5A87EE522F7F88F200D028D0 /* MainContentCoordinator+Favorites.swift */, + 5A87EE532F7F88F200D028D0 /* MainContentCoordinator+Filtering.swift */, + 5A87EE542F7F88F200D028D0 /* MainContentCoordinator+FKNavigation.swift */, + 5A87EE552F7F88F200D028D0 /* MainContentCoordinator+LazyLoadColumns.swift */, + 5A87EE562F7F88F200D028D0 /* MainContentCoordinator+MongoDB.swift */, + 5A87EE572F7F88F200D028D0 /* MainContentCoordinator+MultiStatement.swift */, + 5A87EE582F7F88F200D028D0 /* MainContentCoordinator+Navigation.swift */, + 5A87EE592F7F88F200D028D0 /* MainContentCoordinator+Pagination.swift */, + 5A87EE5A2F7F88F200D028D0 /* MainContentCoordinator+QueryAnalysis.swift */, + 5A87EE5B2F7F88F200D028D0 /* MainContentCoordinator+QueryHelpers.swift */, + 5A87EE5C2F7F88F200D028D0 /* MainContentCoordinator+QuickSwitcher.swift */, + 5A87EE5D2F7F88F200D028D0 /* MainContentCoordinator+Redis.swift */, + 5A87EE5E2F7F88F200D028D0 /* MainContentCoordinator+Refresh.swift */, + 5A87EE5F2F7F88F200D028D0 /* MainContentCoordinator+RowOperations.swift */, + 5A87EE602F7F88F200D028D0 /* MainContentCoordinator+SaveChanges.swift */, + 5A87EE612F7F88F200D028D0 /* MainContentCoordinator+SidebarActions.swift */, + 5A87EE622F7F88F200D028D0 /* MainContentCoordinator+SidebarSave.swift */, + 5A87EE632F7F88F200D028D0 /* MainContentCoordinator+SQLPreview.swift */, + 5A87EE642F7F88F200D028D0 /* MainContentCoordinator+TableOperations.swift */, + 5A87EE652F7F88F200D028D0 /* MainContentCoordinator+TabSwitch.swift */, + 5A87EE662F7F88F200D028D0 /* MainContentCoordinator+URLFilter.swift */, + 5A87EE672F7F88F200D028D0 /* MainContentView+Bindings.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + 5A87EE6E2F7F88F200D028D0 /* Main */ = { + isa = PBXGroup; + children = ( + 5A87EE4B2F7F88F200D028D0 /* Child */, + 5A87EE682F7F88F200D028D0 /* Extensions */, + 5A87EE692F7F88F200D028D0 /* MainContentCommandActions.swift */, + 5A87EE6A2F7F88F200D028D0 /* MainContentCoordinator.swift */, + 5A87EE6B2F7F88F200D028D0 /* MainContentView.swift */, + 5A87EE6C2F7F88F200D028D0 /* SidebarNavigationResult.swift */, + 5A87EE6D2F7F88F200D028D0 /* TableSelectionAction.swift */, + ); + path = Main; + sourceTree = ""; + }; + 5A87EE702F7F88F200D028D0 /* QuickSwitcher */ = { + isa = PBXGroup; + children = ( + 5A87EE6F2F7F88F200D028D0 /* QuickSwitcherView.swift */, + ); + path = QuickSwitcher; + sourceTree = ""; + }; + 5A87EE772F7F88F200D028D0 /* Extensions */ = { + isa = PBXGroup; + children = ( + 5A87EE712F7F88F200D028D0 /* DataGridView+Click.swift */, + 5A87EE722F7F88F200D028D0 /* DataGridView+Columns.swift */, + 5A87EE732F7F88F200D028D0 /* DataGridView+Editing.swift */, + 5A87EE742F7F88F200D028D0 /* DataGridView+Popovers.swift */, + 5A87EE752F7F88F200D028D0 /* DataGridView+Selection.swift */, + 5A87EE762F7F88F200D028D0 /* DataGridView+Sort.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + 5A87EE912F7F88F200D028D0 /* Results */ = { + isa = PBXGroup; + children = ( + 5A87EE772F7F88F200D028D0 /* Extensions */, + 5A87EE782F7F88F200D028D0 /* BooleanCellEditor.swift */, + 5A87EE792F7F88F200D028D0 /* BooleanCellFormatter.swift */, + 5A87EE7A2F7F88F200D028D0 /* CellOverlayEditor.swift */, + 5A87EE7B2F7F88F200D028D0 /* CellTextField.swift */, + 5A87EE7C2F7F88F200D028D0 /* ColumnVisibilityPopover.swift */, + 5A87EE7D2F7F88F200D028D0 /* DataGridCellFactory.swift */, + 5A87EE7E2F7F88F200D028D0 /* DataGridCoordinator.swift */, + 5A87EE7F2F7F88F200D028D0 /* DataGridView.swift */, + 5A87EE802F7F88F200D028D0 /* DataGridView+RowActions.swift */, + 5A87EE812F7F88F200D028D0 /* DataGridView+TypePicker.swift */, + 5A87EE822F7F88F200D028D0 /* DatePickerCellEditor.swift */, + 5A87EE832F7F88F200D028D0 /* EnumPopoverContentView.swift */, + 5A87EE842F7F88F200D028D0 /* ForeignKeyPopoverContentView.swift */, + 5A87EE852F7F88F200D028D0 /* HexEditorContentView.swift */, + 5A87EE862F7F88F200D028D0 /* HistoryDataProvider.swift */, + 5A87EE872F7F88F200D028D0 /* InlineErrorBanner.swift */, + 5A87EE882F7F88F200D028D0 /* JSONBraceMatchingHelper.swift */, + 5A87EE892F7F88F200D028D0 /* JSONEditorContentView.swift */, + 5A87EE8A2F7F88F200D028D0 /* JSONHighlightPatterns.swift */, + 5A87EE8B2F7F88F200D028D0 /* JSONSyntaxTextView.swift */, + 5A87EE8C2F7F88F200D028D0 /* KeyHandlingTableView.swift */, + 5A87EE8D2F7F88F200D028D0 /* ResultSuccessView.swift */, + 5A87EE8E2F7F88F200D028D0 /* ResultTabBar.swift */, + 5A87EE8F2F7F88F200D028D0 /* SetPopoverContentView.swift */, + 5A87EE902F7F88F200D028D0 /* TableRowViewWithMenu.swift */, + ); + path = Results; + sourceTree = ""; + }; + 5A87EE9E2F7F88F200D028D0 /* FieldEditors */ = { + isa = PBXGroup; + children = ( + 5A87EE922F7F88F200D028D0 /* BlobHexEditorView.swift */, + 5A87EE932F7F88F200D028D0 /* BooleanPickerView.swift */, + 5A87EE942F7F88F200D028D0 /* DropdownFieldHelper.swift */, + 5A87EE952F7F88F200D028D0 /* EnumPickerView.swift */, + 5A87EE962F7F88F200D028D0 /* FieldEditorContext.swift */, + 5A87EE972F7F88F200D028D0 /* FieldEditorResolver.swift */, + 5A87EE982F7F88F200D028D0 /* FieldMenuView.swift */, + 5A87EE992F7F88F200D028D0 /* JsonEditorView.swift */, + 5A87EE9A2F7F88F200D028D0 /* MultiLineEditorView.swift */, + 5A87EE9B2F7F88F200D028D0 /* PendingStateOverlay.swift */, + 5A87EE9C2F7F88F200D028D0 /* SetPickerView.swift */, + 5A87EE9D2F7F88F200D028D0 /* SingleLineEditorView.swift */, + ); + path = FieldEditors; + sourceTree = ""; + }; + 5A87EEA22F7F88F200D028D0 /* RightSidebar */ = { + isa = PBXGroup; + children = ( + 5A87EE9E2F7F88F200D028D0 /* FieldEditors */, + 5A87EE9F2F7F88F200D028D0 /* EditableFieldView.swift */, + 5A87EEA02F7F88F200D028D0 /* RightSidebarView.swift */, + 5A87EEA12F7F88F200D028D0 /* UnifiedRightPanelView.swift */, + ); + path = RightSidebar; + sourceTree = ""; + }; + 5A87EEA92F7F88F200D028D0 /* Appearance */ = { + isa = PBXGroup; + children = ( + 5A87EEA32F7F88F200D028D0 /* ThemeEditorColorsSection.swift */, + 5A87EEA42F7F88F200D028D0 /* ThemeEditorFontsSection.swift */, + 5A87EEA52F7F88F200D028D0 /* ThemeEditorLayoutSection.swift */, + 5A87EEA62F7F88F200D028D0 /* ThemeEditorView.swift */, + 5A87EEA72F7F88F200D028D0 /* ThemeListRowView.swift */, + 5A87EEA82F7F88F200D028D0 /* ThemeListView.swift */, + ); + path = Appearance; + sourceTree = ""; + }; + 5A87EEAE2F7F88F200D028D0 /* Plugins */ = { + isa = PBXGroup; + children = ( + 5A87EEAA2F7F88F200D028D0 /* BrowsePluginsView.swift */, + 5A87EEAB2F7F88F200D028D0 /* InstalledPluginsView.swift */, + 5A87EEAC2F7F88F200D028D0 /* PluginIconView.swift */, + 5A87EEAD2F7F88F200D028D0 /* RegistryPluginDetailView.swift */, + ); + path = Plugins; + sourceTree = ""; + }; + 5A87EEBE2F7F88F200D028D0 /* Settings */ = { + isa = PBXGroup; + children = ( + 5A87EEA92F7F88F200D028D0 /* Appearance */, + 5A87EEAE2F7F88F200D028D0 /* Plugins */, + 5A87EEAF2F7F88F200D028D0 /* AISettingsView.swift */, + 5A87EEB02F7F88F200D028D0 /* AppearanceSettingsView.swift */, + 5A87EEB12F7F88F200D028D0 /* DataGridSettingsView.swift */, + 5A87EEB22F7F88F200D028D0 /* EditorSettingsView.swift */, + 5A87EEB32F7F88F200D028D0 /* GeneralSettingsView.swift */, + 5A87EEB42F7F88F200D028D0 /* HistorySettingsView.swift */, + 5A87EEB52F7F88F200D028D0 /* KeyboardSettingsView.swift */, + 5A87EEB62F7F88F200D028D0 /* LicenseActivationSheet.swift */, + 5A87EEB72F7F88F200D028D0 /* LicenseSettingsView.swift */, + 5A87EEB82F7F88F200D028D0 /* LinkedFoldersSection.swift */, + 5A87EEB92F7F88F200D028D0 /* PluginsSettingsView.swift */, + 5A87EEBA2F7F88F200D028D0 /* SettingsView.swift */, + 5A87EEBB2F7F88F200D028D0 /* ShortcutRecorderView.swift */, + 5A87EEBC2F7F88F200D028D0 /* SyncSettingsView.swift */, + 5A87EEBD2F7F88F200D028D0 /* ThemePreviewCard.swift */, + ); + path = Settings; + sourceTree = ""; + }; + 5A87EEC82F7F88F200D028D0 /* Sidebar */ = { + isa = PBXGroup; + children = ( + 5A87EEBF2F7F88F200D028D0 /* DoubleClickDetector.swift */, + 5A87EEC02F7F88F200D028D0 /* FavoriteEditDialog.swift */, + 5A87EEC12F7F88F200D028D0 /* FavoriteRowView.swift */, + 5A87EEC22F7F88F200D028D0 /* FavoritesTabView.swift */, + 5A87EEC32F7F88F200D028D0 /* RedisKeyTreeView.swift */, + 5A87EEC42F7F88F200D028D0 /* SidebarContextMenu.swift */, + 5A87EEC52F7F88F200D028D0 /* SidebarView.swift */, + 5A87EEC62F7F88F200D028D0 /* TableOperationDialog.swift */, + 5A87EEC72F7F88F200D028D0 /* TableRowView.swift */, + ); + path = Sidebar; + sourceTree = ""; + }; + 5A87EED22F7F88F200D028D0 /* Structure */ = { + isa = PBXGroup; + children = ( + 5A87EEC92F7F88F200D028D0 /* ClickHousePartsView.swift */, + 5A87EECA2F7F88F200D028D0 /* CreateTableView.swift */, + 5A87EECB2F7F88F200D028D0 /* DDLTextView.swift */, + 5A87EECC2F7F88F200D028D0 /* SchemaPreviewSheet.swift */, + 5A87EECD2F7F88F200D028D0 /* StructureColumnReorderHandler.swift */, + 5A87EECE2F7F88F200D028D0 /* StructureRowProvider.swift */, + 5A87EECF2F7F88F200D028D0 /* StructureViewActionHandler.swift */, + 5A87EED02F7F88F200D028D0 /* TableStructureView.swift */, + 5A87EED12F7F88F200D028D0 /* TypePickerContentView.swift */, + ); + path = Structure; + sourceTree = ""; + }; + 5A87EED92F7F88F200D028D0 /* Toolbar */ = { + isa = PBXGroup; + children = ( + 5A87EED32F7F88F200D028D0 /* ConnectionStatusView.swift */, + 5A87EED42F7F88F200D028D0 /* ConnectionSwitcherPopover.swift */, + 5A87EED52F7F88F200D028D0 /* ExecutionIndicatorView.swift */, + 5A87EED62F7F88F200D028D0 /* SafeModeBadgeView.swift */, + 5A87EED72F7F88F200D028D0 /* TableProToolbarView.swift */, + 5A87EED82F7F88F200D028D0 /* TagBadgeView.swift */, + ); + path = Toolbar; + sourceTree = ""; + }; + 5A87EEDA2F7F88F200D028D0 /* Views */ = { + isa = PBXGroup; + children = ( + 5A87EE022F7F88F200D028D0 /* About */, + 5A87EE062F7F88F200D028D0 /* AIChat */, + 5A87EE122F7F88F200D028D0 /* Components */, + 5A87EE262F7F88F200D028D0 /* Connection */, + 5A87EE292F7F88F200D028D0 /* DatabaseSwitcher */, + 5A87EE362F7F88F200D028D0 /* Editor */, + 5A87EE3B2F7F88F200D028D0 /* Export */, + 5A87EE422F7F88F200D028D0 /* Filter */, + 5A87EE482F7F88F200D028D0 /* Import */, + 5A87EE6E2F7F88F200D028D0 /* Main */, + 5A87EE702F7F88F200D028D0 /* QuickSwitcher */, + 5A87EE912F7F88F200D028D0 /* Results */, + 5A87EEA22F7F88F200D028D0 /* RightSidebar */, + 5A87EEBE2F7F88F200D028D0 /* Settings */, + 5A87EEC82F7F88F200D028D0 /* Sidebar */, + 5A87EED22F7F88F200D028D0 /* Structure */, + 5A87EED92F7F88F200D028D0 /* Toolbar */, + ); + path = Views; + sourceTree = ""; + }; + 5A87EEE42F7F88F200D028D0 /* TablePro */ = { + isa = PBXGroup; + children = ( + 5A87ED992F7F88F200D028D0 /* Core */, + 5A87EDA62F7F88F200D028D0 /* Extensions */, + 5A87EDE62F7F88F200D028D0 /* Models */, + 5A87EDEF2F7F88F200D028D0 /* Resources */, + 5A87EDF72F7F88F200D028D0 /* Theme */, + 5A87EDFF2F7F88F200D028D0 /* ViewModels */, + 5A87EEDA2F7F88F200D028D0 /* Views */, + 5A87EEDB2F7F88F200D028D0 /* AppDelegate.swift */, + 5A87EEDC2F7F88F200D028D0 /* AppDelegate+ConnectionHandler.swift */, + 5A87EEDD2F7F88F200D028D0 /* AppDelegate+FileOpen.swift */, + 5A87EEDE2F7F88F200D028D0 /* AppDelegate+WindowConfig.swift */, + 5A87EEDF2F7F88F200D028D0 /* Assets.xcassets */, + 5A87EEE02F7F88F200D028D0 /* ContentView.swift */, + 5A87EEE12F7F88F200D028D0 /* Info.plist */, + 5A87EEE22F7F88F200D028D0 /* TablePro.entitlements */, + 5A87EEE32F7F88F200D028D0 /* TableProApp.swift */, + ); + name = TablePro; + path = ../TablePro; + sourceTree = ""; + }; + 5A87EEEB2F7F891F00D028D0 /* TableProSync */ = { + isa = PBXGroup; + children = ( + 5A87EEE52F7F891F00D028D0 /* CloudKitSyncEngine.swift */, + 5A87EEE62F7F891F00D028D0 /* SyncConflict.swift */, + 5A87EEE72F7F891F00D028D0 /* SyncError.swift */, + 5A87EEE82F7F891F00D028D0 /* SyncMetadataStorage.swift */, + 5A87EEE92F7F891F00D028D0 /* SyncRecordMapper.swift */, + 5A87EEEA2F7F891F00D028D0 /* SyncRecordType.swift */, + ); + name = TableProSync; + path = ../Packages/TableProCore/Sources/TableProSync; + sourceTree = ""; + }; + 5AA313332F7EA5B4008EBA97 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 5A87EEEB2F7F891F00D028D0 /* TableProSync */, + 5A87EEE42F7F88F200D028D0 /* TablePro */, + 5AA313532F7EC188008EBA97 /* LibSSH2.xcframework */, + 5AA313352F7EA5B4008EBA97 /* Hiredis.xcframework */, + 5AA313382F7EA5B4008EBA97 /* Hiredis-SSL.xcframework */, + 5AA313342F7EA5B4008EBA97 /* LibPQ.xcframework */, + 5AA313372F7EA5B4008EBA97 /* MariaDB.xcframework */, + 5AA313362F7EA5B4008EBA97 /* OpenSSL-Crypto.xcframework */, + 5AA313392F7EA5B4008EBA97 /* OpenSSL-SSL.xcframework */, + ); + name = Frameworks; + sourceTree = ""; + }; 5AB9F3D02F7C1C12001F3337 = { isa = PBXGroup; children = ( 5AB9F3DB2F7C1C12001F3337 /* TableProMobile */, + 5AA313332F7EA5B4008EBA97 /* Frameworks */, 5AB9F3DA2F7C1C12001F3337 /* Products */, ); sourceTree = ""; @@ -80,6 +1588,7 @@ 5AB9F3EA2F7C1D03001F3337 /* TableProModels */, 5AB9F3EC2F7C1D03001F3337 /* TableProPluginKit */, 5AB9F3EE2F7C1D03001F3337 /* TableProQuery */, + 5A87EEEC2F7F893000D028D0 /* TableProSync */, ); productName = TableProMobile; productReference = 5AB9F3D92F7C1C12001F3337 /* TableProMobile.app */; @@ -284,12 +1793,17 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "-lz", + "-liconv", + ); PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.TableProMobile; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INCLUDE_PATHS = "$(SRCROOT)/TableProMobile/CBridges/CMariaDB $(SRCROOT)/TableProMobile/CBridges/CLibPQ $(SRCROOT)/TableProMobile/CBridges/CRedis $(SRCROOT)/TableProMobile/CBridges/CLibSSH2"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -301,6 +1815,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = TableProMobile/TableProMobileRelease.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = D7HJ5TFYCU; @@ -316,12 +1831,17 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "-lz", + "-liconv", + ); PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.TableProMobile; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INCLUDE_PATHS = "$(SRCROOT)/TableProMobile/CBridges/CMariaDB $(SRCROOT)/TableProMobile/CBridges/CLibPQ $(SRCROOT)/TableProMobile/CBridges/CRedis $(SRCROOT)/TableProMobile/CBridges/CLibSSH2"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -359,6 +1879,11 @@ /* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 5A87EEEC2F7F893000D028D0 /* TableProSync */ = { + isa = XCSwiftPackageProductDependency; + package = 5AB9F3E72F7C1D03001F3337 /* XCLocalSwiftPackageReference "../Packages/TableProCore" */; + productName = TableProSync; + }; 5AB9F3E82F7C1D03001F3337 /* TableProDatabase */ = { isa = XCSwiftPackageProductDependency; productName = TableProDatabase; diff --git a/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift b/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift index 49b5014f1..a2af7632a 100644 --- a/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift +++ b/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift @@ -14,8 +14,15 @@ final class IOSSyncCoordinator { var status: SyncStatus = .idle var lastSyncDate: Date? - private let engine = CloudKitSyncEngine() + private var engine: CloudKitSyncEngine? private let metadata = SyncMetadataStorage() + + private func getEngine() -> CloudKitSyncEngine { + if engine == nil { + engine = CloudKitSyncEngine() + } + return engine! + } private var debounceTask: Task? // Callback to update AppState connections @@ -28,13 +35,13 @@ final class IOSSyncCoordinator { status = .syncing do { - let accountStatus = try await engine.accountStatus() + let accountStatus = try await getEngine().accountStatus() guard accountStatus == .available else { status = .error("iCloud account not available") return } - try await engine.ensureZoneExists() + try await getEngine().ensureZoneExists() try await push(localConnections: localConnections) let remoteConnections = try await pull() let merged = merge(local: localConnections, remote: remoteConnections) @@ -72,7 +79,7 @@ final class IOSSyncCoordinator { // MARK: - Push private func push(localConnections: [DatabaseConnection]) async throws { - let zoneID = await engine.currentZoneID + let zoneID = await getEngine().currentZoneID // Dirty connections let dirtyIDs = metadata.dirtyIDs(for: .connection) @@ -88,7 +95,7 @@ final class IOSSyncCoordinator { guard !dirtyRecords.isEmpty || !deletions.isEmpty else { return } - try await engine.push(records: dirtyRecords, deletions: deletions) + try await getEngine().push(records: dirtyRecords, deletions: deletions) metadata.clearDirty(type: .connection) metadata.clearTombstones(type: .connection) } @@ -97,7 +104,7 @@ final class IOSSyncCoordinator { private func pull() async throws -> [DatabaseConnection] { let token = metadata.loadToken() - let result = try await engine.pull(since: token) + let result = try await getEngine().pull(since: token) if let newToken = result.newToken { metadata.saveToken(newToken) diff --git a/TableProMobile/TableProMobile/TableProMobileRelease.entitlements b/TableProMobile/TableProMobile/TableProMobileRelease.entitlements index 5b983f3cb..cab2bbf54 100644 --- a/TableProMobile/TableProMobile/TableProMobileRelease.entitlements +++ b/TableProMobile/TableProMobile/TableProMobileRelease.entitlements @@ -2,7 +2,15 @@ + aps-environment + development com.apple.developer.icloud-container-identifiers - + + iCloud.com.TablePro + + com.apple.developer.icloud-services + + CloudKit + From cfb83df5d114f077fa5c6c105b69aaccfc702b23 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 3 Apr 2026 12:45:47 +0700 Subject: [PATCH 39/61] fix: case-insensitive database type matching in IOSDriverFactory for macOS sync compatibility --- .../TableProMobile.xcodeproj/project.pbxproj | 1 + .../TableProMobile/Platform/IOSDriverFactory.swift | 14 +++++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/TableProMobile/TableProMobile.xcodeproj/project.pbxproj b/TableProMobile/TableProMobile.xcodeproj/project.pbxproj index 9386717b9..864253868 100644 --- a/TableProMobile/TableProMobile.xcodeproj/project.pbxproj +++ b/TableProMobile/TableProMobile.xcodeproj/project.pbxproj @@ -1778,6 +1778,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = TableProMobile/TableProMobileRelease.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = D7HJ5TFYCU; diff --git a/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift b/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift index 454a154e8..c259aa22e 100644 --- a/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift +++ b/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift @@ -9,10 +9,14 @@ import TableProModels final class IOSDriverFactory: DriverFactory { func createDriver(for connection: DatabaseConnection, password: String?) throws -> any DatabaseDriver { - switch connection.type { - case .sqlite: + // Normalize type for case-insensitive matching + // macOS uses "MySQL"/"PostgreSQL", iOS uses "mysql"/"postgresql" + let typeKey = connection.type.rawValue.lowercased() + + switch typeKey { + case "sqlite": return SQLiteDriver(path: connection.database) - case .mysql, .mariadb: + case "mysql", "mariadb": return MySQLDriver( host: connection.host, port: connection.port, @@ -20,7 +24,7 @@ final class IOSDriverFactory: DriverFactory { password: password ?? "", database: connection.database ) - case .postgresql, .redshift: + case "postgresql", "redshift": return PostgreSQLDriver( host: connection.host, port: connection.port, @@ -28,7 +32,7 @@ final class IOSDriverFactory: DriverFactory { password: password ?? "", database: connection.database ) - case .redis: + case "redis": let dbIndex = Int(connection.database) ?? 0 return RedisDriver( host: connection.host, From a08a0f239b87c52b3ab8a0b393de29b56e0bb690 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 3 Apr 2026 12:53:07 +0700 Subject: [PATCH 40/61] fix: align DatabaseType raw values with macOS for CloudKit sync compatibility --- .../Sources/TableProModels/DatabaseType.swift | 34 +++++++++---------- .../DatabaseTypeTests.swift | 29 +++++++++------- TableProMobile/TableProMobile/AppState.swift | 19 ++++++++++- .../Platform/IOSDriverFactory.swift | 14 +++----- 4 files changed, 56 insertions(+), 40 deletions(-) diff --git a/Packages/TableProCore/Sources/TableProModels/DatabaseType.swift b/Packages/TableProCore/Sources/TableProModels/DatabaseType.swift index a93b279f7..c61208985 100644 --- a/Packages/TableProCore/Sources/TableProModels/DatabaseType.swift +++ b/Packages/TableProCore/Sources/TableProModels/DatabaseType.swift @@ -7,24 +7,24 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable { self.rawValue = rawValue } - // MARK: - Known Constants + // MARK: - Known Constants (raw values match macOS for CloudKit compatibility) - public static let mysql = DatabaseType(rawValue: "mysql") - public static let mariadb = DatabaseType(rawValue: "mariadb") - public static let postgresql = DatabaseType(rawValue: "postgresql") - public static let sqlite = DatabaseType(rawValue: "sqlite") - public static let redis = DatabaseType(rawValue: "redis") - public static let mongodb = DatabaseType(rawValue: "mongodb") - public static let clickhouse = DatabaseType(rawValue: "clickhouse") - public static let mssql = DatabaseType(rawValue: "mssql") - public static let oracle = DatabaseType(rawValue: "oracle") - public static let duckdb = DatabaseType(rawValue: "duckdb") - public static let cassandra = DatabaseType(rawValue: "cassandra") - public static let redshift = DatabaseType(rawValue: "redshift") + public static let mysql = DatabaseType(rawValue: "MySQL") + public static let mariadb = DatabaseType(rawValue: "MariaDB") + public static let postgresql = DatabaseType(rawValue: "PostgreSQL") + public static let sqlite = DatabaseType(rawValue: "SQLite") + public static let redis = DatabaseType(rawValue: "Redis") + public static let mongodb = DatabaseType(rawValue: "MongoDB") + public static let clickhouse = DatabaseType(rawValue: "ClickHouse") + public static let mssql = DatabaseType(rawValue: "SQL Server") + public static let oracle = DatabaseType(rawValue: "Oracle") + public static let duckdb = DatabaseType(rawValue: "DuckDB") + public static let cassandra = DatabaseType(rawValue: "Cassandra") + public static let redshift = DatabaseType(rawValue: "Redshift") public static let etcd = DatabaseType(rawValue: "etcd") - public static let cloudflareD1 = DatabaseType(rawValue: "cloudflared1") - public static let dynamodb = DatabaseType(rawValue: "dynamodb") - public static let bigquery = DatabaseType(rawValue: "bigquery") + public static let cloudflareD1 = DatabaseType(rawValue: "Cloudflare D1") + public static let dynamodb = DatabaseType(rawValue: "DynamoDB") + public static let bigquery = DatabaseType(rawValue: "BigQuery") public static let allKnownTypes: [DatabaseType] = [ .mysql, .mariadb, .postgresql, .sqlite, .redis, .mongodb, @@ -33,7 +33,7 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable { ] /// Plugin type ID for plugin lookup. - /// Multi-type plugins share a single driver: mariadb -> "mysql", redshift -> "postgresql" + /// Multi-type plugins share a single driver: MariaDB -> "MySQL", Redshift -> "PostgreSQL" public var pluginTypeId: String { switch self { case .mariadb: return DatabaseType.mysql.rawValue diff --git a/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift b/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift index ba0542598..af76e5c5d 100644 --- a/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift +++ b/Packages/TableProCore/Tests/TableProModelsTests/DatabaseTypeTests.swift @@ -4,23 +4,26 @@ import Foundation @Suite("DatabaseType Tests") struct DatabaseTypeTests { - @Test("Static constants have correct raw values") + @Test("Static constants have correct raw values matching macOS") func staticConstants() { - #expect(DatabaseType.mysql.rawValue == "mysql") - #expect(DatabaseType.postgresql.rawValue == "postgresql") - #expect(DatabaseType.sqlite.rawValue == "sqlite") - #expect(DatabaseType.redis.rawValue == "redis") - #expect(DatabaseType.mongodb.rawValue == "mongodb") - #expect(DatabaseType.cloudflareD1.rawValue == "cloudflared1") + #expect(DatabaseType.mysql.rawValue == "MySQL") + #expect(DatabaseType.mariadb.rawValue == "MariaDB") + #expect(DatabaseType.postgresql.rawValue == "PostgreSQL") + #expect(DatabaseType.sqlite.rawValue == "SQLite") + #expect(DatabaseType.redis.rawValue == "Redis") + #expect(DatabaseType.mongodb.rawValue == "MongoDB") + #expect(DatabaseType.mssql.rawValue == "SQL Server") + #expect(DatabaseType.cloudflareD1.rawValue == "Cloudflare D1") + #expect(DatabaseType.bigquery.rawValue == "BigQuery") } @Test("pluginTypeId maps multi-type databases") func pluginTypeIdMapping() { - #expect(DatabaseType.mysql.pluginTypeId == "mysql") - #expect(DatabaseType.mariadb.pluginTypeId == "mysql") - #expect(DatabaseType.postgresql.pluginTypeId == "postgresql") - #expect(DatabaseType.redshift.pluginTypeId == "postgresql") - #expect(DatabaseType.sqlite.pluginTypeId == "sqlite") + #expect(DatabaseType.mysql.pluginTypeId == "MySQL") + #expect(DatabaseType.mariadb.pluginTypeId == "MySQL") + #expect(DatabaseType.postgresql.pluginTypeId == "PostgreSQL") + #expect(DatabaseType.redshift.pluginTypeId == "PostgreSQL") + #expect(DatabaseType.sqlite.pluginTypeId == "SQLite") } @Test("Unknown types pass through pluginTypeId") @@ -57,7 +60,7 @@ struct DatabaseTypeTests { func hashableConformance() { var set: Set = [.mysql, .postgresql, .mysql] #expect(set.count == 2) - set.insert(DatabaseType(rawValue: "mysql")) + set.insert(DatabaseType(rawValue: "MySQL")) #expect(set.count == 2) } } diff --git a/TableProMobile/TableProMobile/AppState.swift b/TableProMobile/TableProMobile/AppState.swift index c01f25b7e..ef4dc74b9 100644 --- a/TableProMobile/TableProMobile/AppState.swift +++ b/TableProMobile/TableProMobile/AppState.swift @@ -81,7 +81,24 @@ private struct ConnectionPersistence { let connections = try? JSONDecoder().decode([DatabaseConnection].self, from: data) else { return migrateFromUserDefaults() } - return connections + let migrated = connections.map { migrateTypeIfNeeded($0) } + if migrated != connections { save(migrated) } + return migrated + } + + private func migrateTypeIfNeeded(_ connection: DatabaseConnection) -> DatabaseConnection { + let typeMap: [String: DatabaseType] = [ + "mysql": .mysql, "mariadb": .mariadb, "postgresql": .postgresql, + "sqlite": .sqlite, "redis": .redis, "mongodb": .mongodb, + "clickhouse": .clickhouse, "mssql": .mssql, "oracle": .oracle, + "duckdb": .duckdb, "cassandra": .cassandra, "redshift": .redshift, + "etcd": .etcd, "cloudflared1": .cloudflareD1, "dynamodb": .dynamodb, + "bigquery": .bigquery, + ] + guard let corrected = typeMap[connection.type.rawValue] else { return connection } + var migrated = connection + migrated.type = corrected + return migrated } private func migrateFromUserDefaults() -> [DatabaseConnection] { diff --git a/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift b/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift index c259aa22e..454a154e8 100644 --- a/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift +++ b/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift @@ -9,14 +9,10 @@ import TableProModels final class IOSDriverFactory: DriverFactory { func createDriver(for connection: DatabaseConnection, password: String?) throws -> any DatabaseDriver { - // Normalize type for case-insensitive matching - // macOS uses "MySQL"/"PostgreSQL", iOS uses "mysql"/"postgresql" - let typeKey = connection.type.rawValue.lowercased() - - switch typeKey { - case "sqlite": + switch connection.type { + case .sqlite: return SQLiteDriver(path: connection.database) - case "mysql", "mariadb": + case .mysql, .mariadb: return MySQLDriver( host: connection.host, port: connection.port, @@ -24,7 +20,7 @@ final class IOSDriverFactory: DriverFactory { password: password ?? "", database: connection.database ) - case "postgresql", "redshift": + case .postgresql, .redshift: return PostgreSQLDriver( host: connection.host, port: connection.port, @@ -32,7 +28,7 @@ final class IOSDriverFactory: DriverFactory { password: password ?? "", database: connection.database ) - case "redis": + case .redis: let dbIndex = Int(connection.database) ?? 0 return RedisDriver( host: connection.host, From 77a0dfd5a5f35562a81feef365f86ae052ecf531 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 3 Apr 2026 12:58:56 +0700 Subject: [PATCH 41/61] =?UTF-8?q?fix:=20align=20SSHConfiguration=20with=20?= =?UTF-8?q?macOS=20Codable=20format=20=E2=80=94=20tolerant=20decoding,=20m?= =?UTF-8?q?atching=20auth=20method=20names?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TableProModels/SSHConfiguration.swift | 57 ++++++++++++++++++- .../TableProMobile/SSH/SSHTunnelFactory.swift | 2 +- .../Views/ConnectionFormView.swift | 2 +- 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/Packages/TableProCore/Sources/TableProModels/SSHConfiguration.swift b/Packages/TableProCore/Sources/TableProModels/SSHConfiguration.swift index ac718bb19..f00d70135 100644 --- a/Packages/TableProCore/Sources/TableProModels/SSHConfiguration.swift +++ b/Packages/TableProCore/Sources/TableProModels/SSHConfiguration.swift @@ -8,10 +8,35 @@ public struct SSHConfiguration: Codable, Hashable, Sendable { public var privateKeyPath: String? public var jumpHosts: [SSHJumpHost] + // Raw values match macOS for CloudKit sync compatibility public enum SSHAuthMethod: String, Codable, Sendable { case password - case publicKey - case agent + case privateKey // macOS uses "privateKey" + case publicKey // alias — decoded from "publicKey" if ever stored + case sshAgent // macOS uses "sshAgent" + case keyboardInteractive + case agent // alias — decoded from "agent" if ever stored + + public init(from decoder: Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + switch raw { + case "password": self = .password + case "privateKey", "publicKey": self = .privateKey + case "sshAgent", "agent": self = .sshAgent + case "keyboardInteractive": self = .keyboardInteractive + default: self = .password + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .password: try container.encode("password") + case .privateKey, .publicKey: try container.encode("privateKey") + case .sshAgent, .agent: try container.encode("sshAgent") + case .keyboardInteractive: try container.encode("keyboardInteractive") + } + } } public init( @@ -29,6 +54,34 @@ public struct SSHConfiguration: Codable, Hashable, Sendable { self.privateKeyPath = privateKeyPath self.jumpHosts = jumpHosts } + + // Custom Codable to handle macOS extra fields gracefully + private enum CodingKeys: String, CodingKey { + case host, port, username, authMethod, privateKeyPath, jumpHosts + // macOS-only fields we read but ignore + case enabled, useSSHConfig, agentSocketPath + case totpMode, totpAlgorithm, totpDigits, totpPeriod + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + host = (try? container.decode(String.self, forKey: .host)) ?? "" + port = (try? container.decode(Int.self, forKey: .port)) ?? 22 + username = (try? container.decode(String.self, forKey: .username)) ?? "" + authMethod = (try? container.decode(SSHAuthMethod.self, forKey: .authMethod)) ?? .password + privateKeyPath = try? container.decode(String.self, forKey: .privateKeyPath) + jumpHosts = (try? container.decode([SSHJumpHost].self, forKey: .jumpHosts)) ?? [] + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(host, forKey: .host) + try container.encode(port, forKey: .port) + try container.encode(username, forKey: .username) + try container.encode(authMethod, forKey: .authMethod) + try container.encodeIfPresent(privateKeyPath, forKey: .privateKeyPath) + try container.encode(jumpHosts, forKey: .jumpHosts) + } } public struct SSHJumpHost: Codable, Hashable, Sendable, Identifiable { diff --git a/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift b/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift index 52bdd6989..a57413823 100644 --- a/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift +++ b/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift @@ -36,7 +36,7 @@ enum SSHTunnelFactory { } try await tunnel.authenticatePassword(username: config.username, password: password) - case .publicKey: + case .privateKey, .publicKey: guard let keyPath = config.privateKeyPath else { throw SSHTunnelError.authenticationFailed("No private key path provided") } diff --git a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift index 90ee1979a..c1e35909f 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift @@ -262,7 +262,7 @@ struct ConnectionFormView: View { Picker("Auth Method", selection: $sshAuthMethod) { Text("Password").tag(SSHConfiguration.SSHAuthMethod.password) - Text("Private Key").tag(SSHConfiguration.SSHAuthMethod.publicKey) + Text("Private Key").tag(SSHConfiguration.SSHAuthMethod.privateKey) } } From a3406afc13704f376ec6059732d8ebf101f4eb99 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 3 Apr 2026 13:00:31 +0700 Subject: [PATCH 42/61] =?UTF-8?q?refactor:=20clean=20SSHAuthMethod=20?= =?UTF-8?q?=E2=80=94=20remove=20duplicate=20cases,=20move=20type=20migrati?= =?UTF-8?q?on=20to=20DatabaseType.normalized?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/TableProModels/DatabaseType.swift | 14 +++++++++++ .../TableProModels/SSHConfiguration.swift | 17 ++----------- TableProMobile/TableProMobile/AppState.swift | 25 ++++++------------- .../TableProMobile/SSH/SSHTunnelFactory.swift | 2 +- 4 files changed, 24 insertions(+), 34 deletions(-) diff --git a/Packages/TableProCore/Sources/TableProModels/DatabaseType.swift b/Packages/TableProCore/Sources/TableProModels/DatabaseType.swift index c61208985..989bc1379 100644 --- a/Packages/TableProCore/Sources/TableProModels/DatabaseType.swift +++ b/Packages/TableProCore/Sources/TableProModels/DatabaseType.swift @@ -32,6 +32,20 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable { .etcd, .cloudflareD1, .dynamodb, .bigquery ] + /// Normalize legacy lowercase raw values to canonical form. + /// Used when loading connections persisted before raw value alignment. + public var normalized: DatabaseType { + let map: [String: DatabaseType] = [ + "mysql": .mysql, "mariadb": .mariadb, "postgresql": .postgresql, + "sqlite": .sqlite, "redis": .redis, "mongodb": .mongodb, + "clickhouse": .clickhouse, "mssql": .mssql, "oracle": .oracle, + "duckdb": .duckdb, "cassandra": .cassandra, "redshift": .redshift, + "etcd": .etcd, "cloudflared1": .cloudflareD1, "dynamodb": .dynamodb, + "bigquery": .bigquery, + ] + return map[rawValue] ?? self + } + /// Plugin type ID for plugin lookup. /// Multi-type plugins share a single driver: MariaDB -> "MySQL", Redshift -> "PostgreSQL" public var pluginTypeId: String { diff --git a/Packages/TableProCore/Sources/TableProModels/SSHConfiguration.swift b/Packages/TableProCore/Sources/TableProModels/SSHConfiguration.swift index f00d70135..5f67a2ba5 100644 --- a/Packages/TableProCore/Sources/TableProModels/SSHConfiguration.swift +++ b/Packages/TableProCore/Sources/TableProModels/SSHConfiguration.swift @@ -8,14 +8,11 @@ public struct SSHConfiguration: Codable, Hashable, Sendable { public var privateKeyPath: String? public var jumpHosts: [SSHJumpHost] - // Raw values match macOS for CloudKit sync compatibility public enum SSHAuthMethod: String, Codable, Sendable { case password - case privateKey // macOS uses "privateKey" - case publicKey // alias — decoded from "publicKey" if ever stored - case sshAgent // macOS uses "sshAgent" + case privateKey + case sshAgent case keyboardInteractive - case agent // alias — decoded from "agent" if ever stored public init(from decoder: Decoder) throws { let raw = try decoder.singleValueContainer().decode(String.self) @@ -27,16 +24,6 @@ public struct SSHConfiguration: Codable, Hashable, Sendable { default: self = .password } } - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self { - case .password: try container.encode("password") - case .privateKey, .publicKey: try container.encode("privateKey") - case .sshAgent, .agent: try container.encode("sshAgent") - case .keyboardInteractive: try container.encode("keyboardInteractive") - } - } } public init( diff --git a/TableProMobile/TableProMobile/AppState.swift b/TableProMobile/TableProMobile/AppState.swift index ef4dc74b9..403e7f0d3 100644 --- a/TableProMobile/TableProMobile/AppState.swift +++ b/TableProMobile/TableProMobile/AppState.swift @@ -81,24 +81,13 @@ private struct ConnectionPersistence { let connections = try? JSONDecoder().decode([DatabaseConnection].self, from: data) else { return migrateFromUserDefaults() } - let migrated = connections.map { migrateTypeIfNeeded($0) } - if migrated != connections { save(migrated) } - return migrated - } - - private func migrateTypeIfNeeded(_ connection: DatabaseConnection) -> DatabaseConnection { - let typeMap: [String: DatabaseType] = [ - "mysql": .mysql, "mariadb": .mariadb, "postgresql": .postgresql, - "sqlite": .sqlite, "redis": .redis, "mongodb": .mongodb, - "clickhouse": .clickhouse, "mssql": .mssql, "oracle": .oracle, - "duckdb": .duckdb, "cassandra": .cassandra, "redshift": .redshift, - "etcd": .etcd, "cloudflared1": .cloudflareD1, "dynamodb": .dynamodb, - "bigquery": .bigquery, - ] - guard let corrected = typeMap[connection.type.rawValue] else { return connection } - var migrated = connection - migrated.type = corrected - return migrated + let normalized = connections.map { conn -> DatabaseConnection in + var c = conn + c.type = conn.type.normalized + return c + } + if normalized != connections { save(normalized) } + return normalized } private func migrateFromUserDefaults() -> [DatabaseConnection] { diff --git a/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift b/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift index a57413823..7375a997f 100644 --- a/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift +++ b/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift @@ -36,7 +36,7 @@ enum SSHTunnelFactory { } try await tunnel.authenticatePassword(username: config.username, password: password) - case .privateKey, .publicKey: + case .privateKey: guard let keyPath = config.privateKeyPath else { throw SSHTunnelError.authenticationFailed("No private key path provided") } From da1464a995d3e7d22ed89c99698d436d92a8f91a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 3 Apr 2026 13:07:26 +0700 Subject: [PATCH 43/61] fix: derive sshEnabled from sshConfigJson for macOS CloudKit compatibility --- .../Sources/TableProSync/SyncRecordMapper.swift | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift b/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift index 0b3eae3ac..025de8f29 100644 --- a/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift +++ b/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift @@ -104,14 +104,25 @@ public enum SyncRecordMapper { let sortOrder = (record["sortOrder"] as? Int64).map { Int($0) } ?? 0 let isReadOnly = (record["isReadOnly"] as? Int64 ?? 0) != 0 let queryTimeout = (record["queryTimeoutSeconds"] as? Int64).map { Int($0) } - let sshEnabled = (record["sshEnabled"] as? Int64 ?? 0) != 0 - let sslEnabled = (record["sslEnabled"] as? Int64 ?? 0) != 0 - var sshConfig: SSHConfiguration? if let sshData = record["sshConfigJson"] as? Data { sshConfig = try? decoder.decode(SSHConfiguration.self, from: sshData) } + // macOS stores SSH enabled inside sshConfigJson ("enabled" field), + // not as a top-level CKRecord field. Fall back to checking the JSON. + let sshEnabled: Bool + if let explicit = record["sshEnabled"] as? Int64 { + sshEnabled = explicit != 0 + } else if let sshData = record["sshConfigJson"] as? Data, + let json = try? JSONSerialization.jsonObject(with: sshData) as? [String: Any] { + sshEnabled = json["enabled"] as? Bool ?? (sshConfig != nil && !(sshConfig?.host.isEmpty ?? true)) + } else { + sshEnabled = false + } + + let sslEnabled = (record["sslEnabled"] as? Int64 ?? 0) != 0 + var sslConfig: SSLConfiguration? if let sslData = record["sslConfigJson"] as? Data { sslConfig = try? decoder.decode(SSLConfiguration.self, from: sslData) From 1b0bec373efa1b7760e4274d15df98091c886924 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 3 Apr 2026 13:10:57 +0700 Subject: [PATCH 44/61] fix: add MariaDB to ConnectionFormView database type picker --- TableProMobile/TableProMobile/Views/ConnectionFormView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift index c1e35909f..a8dbf82db 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift @@ -47,6 +47,7 @@ struct ConnectionFormView: View { private let databaseTypes: [(DatabaseType, String)] = [ (.mysql, "MySQL"), + (.mariadb, "MariaDB"), (.postgresql, "PostgreSQL"), (.sqlite, "SQLite"), (.redis, "Redis"), From 9f3dbe23f025b71f34d0cfb853c235cf5618e94a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 3 Apr 2026 13:15:01 +0700 Subject: [PATCH 45/61] fix: handle macOS capitalized SSH auth method raw values in decoder --- .../Sources/TableProModels/SSHConfiguration.swift | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/Packages/TableProCore/Sources/TableProModels/SSHConfiguration.swift b/Packages/TableProCore/Sources/TableProModels/SSHConfiguration.swift index 5f67a2ba5..452280a6c 100644 --- a/Packages/TableProCore/Sources/TableProModels/SSHConfiguration.swift +++ b/Packages/TableProCore/Sources/TableProModels/SSHConfiguration.swift @@ -17,11 +17,16 @@ public struct SSHConfiguration: Codable, Hashable, Sendable { public init(from decoder: Decoder) throws { let raw = try decoder.singleValueContainer().decode(String.self) switch raw { - case "password": self = .password - case "privateKey", "publicKey": self = .privateKey - case "sshAgent", "agent": self = .sshAgent - case "keyboardInteractive": self = .keyboardInteractive - default: self = .password + case "password", "Password": + self = .password + case "privateKey", "publicKey", "Private Key": + self = .privateKey + case "sshAgent", "agent", "SSH Agent": + self = .sshAgent + case "keyboardInteractive", "Keyboard Interactive": + self = .keyboardInteractive + default: + self = .password } } } From 5b21f19ce79ff74f00e019419bab8f66fe783eab Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 3 Apr 2026 13:36:59 +0700 Subject: [PATCH 46/61] =?UTF-8?q?fix:=20sync=20compatibility=20=E2=80=94?= =?UTF-8?q?=20SSL=20decoder,=20color=20field=20name,=20CKRecord=20passthro?= =?UTF-8?q?ugh=20for=20macOS-only=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TableProModels/SSLConfiguration.swift | 23 ++++++++ .../TableProSync/SyncRecordMapper.swift | 52 ++++++++++++++++++- .../Sync/IOSSyncCoordinator.swift | 11 +++- 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/Packages/TableProCore/Sources/TableProModels/SSLConfiguration.swift b/Packages/TableProCore/Sources/TableProModels/SSLConfiguration.swift index ec1568254..c1ad7fd45 100644 --- a/Packages/TableProCore/Sources/TableProModels/SSLConfiguration.swift +++ b/Packages/TableProCore/Sources/TableProModels/SSLConfiguration.swift @@ -11,6 +11,29 @@ public struct SSLConfiguration: Codable, Hashable, Sendable { case require case verifyCa case verifyFull + + public init(from decoder: Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + switch raw { + case "disable", "Disabled": self = .disable + case "require", "Required", "Preferred": self = .require + case "verifyCa", "Verify CA": self = .verifyCa + case "verifyFull", "Verify Identity": self = .verifyFull + default: self = .disable + } + } + } + + private enum CodingKeys: String, CodingKey { + case mode, caCertificatePath, clientCertificatePath, clientKeyPath + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + mode = (try? container.decode(SSLMode.self, forKey: .mode)) ?? .disable + caCertificatePath = try? container.decode(String.self, forKey: .caCertificatePath) + clientCertificatePath = try? container.decode(String.self, forKey: .clientCertificatePath) + clientKeyPath = try? container.decode(String.self, forKey: .clientKeyPath) } public init( diff --git a/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift b/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift index 025de8f29..23726e402 100644 --- a/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift +++ b/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift @@ -41,6 +41,7 @@ public enum SyncRecordMapper { record["sslEnabled"] = Int64(connection.sslEnabled ? 1 : 0) as CKRecordValue if let colorTag = connection.colorTag { + record["color"] = colorTag as CKRecordValue record["colorTag"] = colorTag as CKRecordValue } if let groupId = connection.groupId { @@ -99,7 +100,7 @@ public enum SyncRecordMapper { let port = (record["port"] as? Int64).map { Int($0) } ?? 3306 let database = record["database"] as? String ?? "" let username = record["username"] as? String ?? "" - let colorTag = record["colorTag"] as? String + let colorTag = record["color"] as? String ?? record["colorTag"] as? String let groupId = (record["groupId"] as? String).flatMap { UUID(uuidString: $0) } let sortOrder = (record["sortOrder"] as? Int64).map { Int($0) } ?? 0 let isReadOnly = (record["isReadOnly"] as? Int64 ?? 0) != 0 @@ -154,6 +155,55 @@ public enum SyncRecordMapper { ) } + // MARK: - Update Existing CKRecord (preserves macOS-only fields) + + public static func updateRecord(_ record: CKRecord, with connection: DatabaseConnection) { + record["connectionId"] = connection.id.uuidString as CKRecordValue + record["name"] = connection.name as CKRecordValue + record["host"] = connection.host as CKRecordValue + record["port"] = Int64(connection.port) as CKRecordValue + record["database"] = connection.database as CKRecordValue + record["username"] = connection.username as CKRecordValue + record["type"] = connection.type.rawValue as CKRecordValue + record["sortOrder"] = Int64(connection.sortOrder) as CKRecordValue + record["isReadOnly"] = Int64(connection.isReadOnly ? 1 : 0) as CKRecordValue + record["sshEnabled"] = Int64(connection.sshEnabled ? 1 : 0) as CKRecordValue + record["sslEnabled"] = Int64(connection.sslEnabled ? 1 : 0) as CKRecordValue + + if let colorTag = connection.colorTag { + record["color"] = colorTag as CKRecordValue + record["colorTag"] = colorTag as CKRecordValue + } + + if let groupId = connection.groupId { + record["groupId"] = groupId.uuidString as CKRecordValue + } + + if let queryTimeout = connection.queryTimeoutSeconds { + record["queryTimeoutSeconds"] = Int64(queryTimeout) as CKRecordValue + } + + if let sshConfig = connection.sshConfiguration { + if let data = try? encoder.encode(sshConfig) { + record["sshConfigJson"] = data as CKRecordValue + } + } + + if let sslConfig = connection.sslConfiguration { + if let data = try? encoder.encode(sslConfig) { + record["sslConfigJson"] = data as CKRecordValue + } + } + + if !connection.additionalFields.isEmpty { + if let data = try? encoder.encode(connection.additionalFields) { + record["additionalFieldsJson"] = data as CKRecordValue + } + } + + record["modifiedAtLocal"] = Date() as CKRecordValue + } + // MARK: - Group -> CKRecord public static func toRecord(_ group: ConnectionGroup, zoneID: CKRecordZone.ID) -> CKRecord { diff --git a/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift b/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift index a2af7632a..066175bfa 100644 --- a/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift +++ b/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift @@ -16,6 +16,7 @@ final class IOSSyncCoordinator { private var engine: CloudKitSyncEngine? private let metadata = SyncMetadataStorage() + private var cachedRecords: [UUID: CKRecord] = [:] private func getEngine() -> CloudKitSyncEngine { if engine == nil { @@ -85,7 +86,14 @@ final class IOSSyncCoordinator { let dirtyIDs = metadata.dirtyIDs(for: .connection) let dirtyRecords = localConnections .filter { dirtyIDs.contains($0.id.uuidString) } - .map { SyncRecordMapper.toRecord($0, zoneID: zoneID) } + .map { connection -> CKRecord in + if let existing = cachedRecords[connection.id] { + SyncRecordMapper.updateRecord(existing, with: connection) + return existing + } else { + return SyncRecordMapper.toRecord(connection, zoneID: zoneID) + } + } // Tombstones let tombstones = metadata.tombstones(for: .connection) @@ -115,6 +123,7 @@ final class IOSSyncCoordinator { for record in result.changedRecords { if record.recordType == SyncRecordType.connection.rawValue { if let connection = SyncRecordMapper.toConnection(record) { + cachedRecords[connection.id] = record connections.append(connection) } } From 13765135b723979003417b4718dc27abf9ca4221 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 3 Apr 2026 14:00:03 +0700 Subject: [PATCH 47/61] =?UTF-8?q?feat:=20SSH=20private=20key=20=E2=80=94?= =?UTF-8?q?=20support=20both=20file=20import=20and=20paste=20key=20content?= =?UTF-8?q?=20via=20libssh2=5Ffrommemory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TableProModels/SSHConfiguration.swift | 7 ++- .../TableProMobile/SSH/SSHTunnel.swift | 23 ++++++++ .../TableProMobile/SSH/SSHTunnelFactory.swift | 21 ++++--- .../Views/ConnectionFormView.swift | 58 +++++++++++++++---- 4 files changed, 91 insertions(+), 18 deletions(-) diff --git a/Packages/TableProCore/Sources/TableProModels/SSHConfiguration.swift b/Packages/TableProCore/Sources/TableProModels/SSHConfiguration.swift index 452280a6c..a2302e847 100644 --- a/Packages/TableProCore/Sources/TableProModels/SSHConfiguration.swift +++ b/Packages/TableProCore/Sources/TableProModels/SSHConfiguration.swift @@ -6,6 +6,7 @@ public struct SSHConfiguration: Codable, Hashable, Sendable { public var username: String public var authMethod: SSHAuthMethod public var privateKeyPath: String? + public var privateKeyData: String? public var jumpHosts: [SSHJumpHost] public enum SSHAuthMethod: String, Codable, Sendable { @@ -37,6 +38,7 @@ public struct SSHConfiguration: Codable, Hashable, Sendable { username: String = "", authMethod: SSHAuthMethod = .password, privateKeyPath: String? = nil, + privateKeyData: String? = nil, jumpHosts: [SSHJumpHost] = [] ) { self.host = host @@ -44,12 +46,13 @@ public struct SSHConfiguration: Codable, Hashable, Sendable { self.username = username self.authMethod = authMethod self.privateKeyPath = privateKeyPath + self.privateKeyData = privateKeyData self.jumpHosts = jumpHosts } // Custom Codable to handle macOS extra fields gracefully private enum CodingKeys: String, CodingKey { - case host, port, username, authMethod, privateKeyPath, jumpHosts + case host, port, username, authMethod, privateKeyPath, privateKeyData, jumpHosts // macOS-only fields we read but ignore case enabled, useSSHConfig, agentSocketPath case totpMode, totpAlgorithm, totpDigits, totpPeriod @@ -62,6 +65,7 @@ public struct SSHConfiguration: Codable, Hashable, Sendable { username = (try? container.decode(String.self, forKey: .username)) ?? "" authMethod = (try? container.decode(SSHAuthMethod.self, forKey: .authMethod)) ?? .password privateKeyPath = try? container.decode(String.self, forKey: .privateKeyPath) + privateKeyData = try? container.decode(String.self, forKey: .privateKeyData) jumpHosts = (try? container.decode([SSHJumpHost].self, forKey: .jumpHosts)) ?? [] } @@ -72,6 +76,7 @@ public struct SSHConfiguration: Codable, Hashable, Sendable { try container.encode(username, forKey: .username) try container.encode(authMethod, forKey: .authMethod) try container.encodeIfPresent(privateKeyPath, forKey: .privateKeyPath) + try container.encodeIfPresent(privateKeyData, forKey: .privateKeyData) try container.encode(jumpHosts, forKey: .jumpHosts) } } diff --git a/TableProMobile/TableProMobile/SSH/SSHTunnel.swift b/TableProMobile/TableProMobile/SSH/SSHTunnel.swift index 0cf35ee88..6fb564519 100644 --- a/TableProMobile/TableProMobile/SSH/SSHTunnel.swift +++ b/TableProMobile/TableProMobile/SSH/SSHTunnel.swift @@ -173,6 +173,29 @@ actor SSHTunnel { Self.logger.debug("Public key authentication successful for \(username)") } + func authenticatePublicKeyFromMemory(username: String, keyContent: String, passphrase: String?) throws { + guard let session else { + throw SSHTunnelError.authenticationFailed("No active session") + } + + let rc = keyContent.withCString { keyPtr in + libssh2_userauth_publickey_frommemory( + session, + username, + username.utf8.count, + nil, 0, + keyPtr, keyContent.utf8.count, + passphrase + ) + } + + if rc != 0 { + throw SSHTunnelError.authenticationFailed("In-memory key authentication failed (error \(rc))") + } + + Self.logger.debug("In-memory key authentication successful for \(username)") + } + // MARK: - Port Forwarding func startForwarding(remoteHost: String, remotePort: Int) throws { diff --git a/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift b/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift index 7375a997f..c50ff0ba0 100644 --- a/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift +++ b/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift @@ -37,14 +37,21 @@ enum SSHTunnelFactory { try await tunnel.authenticatePassword(username: config.username, password: password) case .privateKey: - guard let keyPath = config.privateKeyPath else { - throw SSHTunnelError.authenticationFailed("No private key path provided") + if let keyContent = config.privateKeyData, !keyContent.isEmpty { + try await tunnel.authenticatePublicKeyFromMemory( + username: config.username, + keyContent: keyContent, + passphrase: keyPassphrase + ) + } else if let keyPath = config.privateKeyPath, !keyPath.isEmpty { + try await tunnel.authenticatePublicKey( + username: config.username, + keyPath: keyPath, + passphrase: keyPassphrase + ) + } else { + throw SSHTunnelError.authenticationFailed("No private key provided") } - try await tunnel.authenticatePublicKey( - username: config.username, - keyPath: keyPath, - passphrase: keyPassphrase - ) default: throw SSHTunnelError.authenticationFailed( diff --git a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift index a8dbf82db..04dbdfe90 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift @@ -35,9 +35,16 @@ struct ConnectionFormView: View { @State private var sshPassword = "" @State private var sshAuthMethod: SSHConfiguration.SSHAuthMethod = .password @State private var sshKeyPath = "" + @State private var sshKeyContent = "" @State private var sshKeyPassphrase = "" + @State private var sshKeyInputMode = KeyInputMode.file @State private var showSSHKeyPicker = false + enum KeyInputMode: String, CaseIterable { + case file = "Import File" + case paste = "Paste Key" + } + // Test connection @State private var isTesting = false @State private var testResult: TestResult? @@ -71,6 +78,10 @@ struct ConnectionFormView: View { _sshUsername = State(initialValue: ssh.username) _sshAuthMethod = State(initialValue: ssh.authMethod) _sshKeyPath = State(initialValue: ssh.privateKeyPath ?? "") + _sshKeyContent = State(initialValue: ssh.privateKeyData ?? "") + if ssh.privateKeyData != nil && !ssh.privateKeyData!.isEmpty { + _sshKeyInputMode = State(initialValue: .paste) + } } if connection.type == .sqlite { _selectedFileURL = State(initialValue: URL(fileURLWithPath: connection.database)) @@ -273,17 +284,43 @@ struct ConnectionFormView: View { } } else { Section("Private Key") { - Button { - showSSHKeyPicker = true - } label: { - HStack { - Text(sshKeyPath.isEmpty - ? "Select Private Key" - : URL(fileURLWithPath: sshKeyPath).lastPathComponent) - Spacer() - Image(systemName: "folder") + Picker("Input Method", selection: $sshKeyInputMode) { + ForEach(KeyInputMode.allCases, id: \.self) { mode in + Text(mode.rawValue).tag(mode) } } + .pickerStyle(.segmented) + + if sshKeyInputMode == .file { + Button { + showSSHKeyPicker = true + } label: { + HStack { + Text(sshKeyPath.isEmpty + ? "Select Private Key" + : URL(fileURLWithPath: sshKeyPath).lastPathComponent) + Spacer() + Image(systemName: "folder") + } + } + } else { + TextEditor(text: $sshKeyContent) + .font(.system(.caption, design: .monospaced)) + .frame(minHeight: 120) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .overlay(alignment: .topLeading) { + if sshKeyContent.isEmpty { + Text("Paste private key (PEM format)") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.tertiary) + .padding(.top, 8) + .padding(.leading, 4) + .allowsHitTesting(false) + } + } + } + SecureField("Passphrase (optional)", text: $sshKeyPassphrase) } } @@ -417,7 +454,8 @@ struct ConnectionFormView: View { port: Int(sshPort) ?? 22, username: sshUsername, authMethod: sshAuthMethod, - privateKeyPath: sshKeyPath.isEmpty ? nil : sshKeyPath + privateKeyPath: sshKeyPath.isEmpty ? nil : sshKeyPath, + privateKeyData: sshKeyContent.isEmpty ? nil : sshKeyContent ) } return conn From e3e4481ec7c984a6dcad03d632c669d2bee69049 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 3 Apr 2026 14:18:02 +0700 Subject: [PATCH 48/61] =?UTF-8?q?fix:=20final=20audit=20=E2=80=94=2015=20f?= =?UTF-8?q?ixes=20including=20security=20(private=20key=20sync,=20SQL=20in?= =?UTF-8?q?jection),=20resource=20leaks,=20architecture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TableProSync/SyncRecordMapper.swift | 8 ++- TableProMobile/TableProMobile/AppState.swift | 2 - .../TableProMobile/Drivers/MySQLDriver.swift | 4 +- .../Drivers/PostgreSQLDriver.swift | 17 +++--- .../TableProMobile/Drivers/RedisDriver.swift | 2 + .../TableProMobile/SSH/IOSSSHProvider.swift | 14 +++-- .../TableProMobile/SSH/SSHTunnel.swift | 6 +- .../Sync/IOSSyncCoordinator.swift | 16 +++-- .../Views/ConnectionFormView.swift | 59 +++++-------------- .../TableProMobile/Views/InsertRowView.swift | 2 +- .../Views/QueryEditorView.swift | 4 +- 11 files changed, 60 insertions(+), 74 deletions(-) diff --git a/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift b/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift index 23726e402..02aa431c6 100644 --- a/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift +++ b/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift @@ -53,7 +53,9 @@ public enum SyncRecordMapper { if let sshConfig = connection.sshConfiguration { do { - let data = try encoder.encode(sshConfig) + var syncSafe = sshConfig + syncSafe.privateKeyData = nil + let data = try encoder.encode(syncSafe) record["sshConfigJson"] = data as CKRecordValue } catch { logger.warning("Failed to encode SSH config for sync: \(error.localizedDescription)") @@ -184,7 +186,9 @@ public enum SyncRecordMapper { } if let sshConfig = connection.sshConfiguration { - if let data = try? encoder.encode(sshConfig) { + var syncSafe = sshConfig + syncSafe.privateKeyData = nil + if let data = try? encoder.encode(syncSafe) { record["sshConfigJson"] = data as CKRecordValue } } diff --git a/TableProMobile/TableProMobile/AppState.swift b/TableProMobile/TableProMobile/AppState.swift index 403e7f0d3..0a9543f38 100644 --- a/TableProMobile/TableProMobile/AppState.swift +++ b/TableProMobile/TableProMobile/AppState.swift @@ -32,8 +32,6 @@ final class AppState { self.connections = merged self.storage.save(merged) } - - Task { await syncCoordinator.sync(localConnections: connections) } } func addConnection(_ connection: DatabaseConnection) { diff --git a/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift b/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift index 90e247f7a..a5657c8e5 100644 --- a/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift @@ -142,8 +142,8 @@ final class MySQLDriver: DatabaseDriver, @unchecked Sendable { } func fetchForeignKeys(table: String, schema: String?) async throws -> [ForeignKeyInfo] { - let safe = table.replacingOccurrences(of: "'", with: "\\'") - let dbSafe = database.replacingOccurrences(of: "'", with: "\\'") + let safe = table.replacingOccurrences(of: "'", with: "''") + let dbSafe = database.replacingOccurrences(of: "'", with: "''") let query = """ SELECT kcu.CONSTRAINT_NAME, diff --git a/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift b/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift index d6101d286..ca82888e7 100644 --- a/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift @@ -275,14 +275,10 @@ private actor PostgreSQLActor { private var conn: OpaquePointer? func connect(host: String, port: Int, user: String, password: String, database: String) throws { - let escapedHost = host.replacingOccurrences(of: "'", with: "\\'") - .replacingOccurrences(of: "\\", with: "\\\\") - let escapedUser = user.replacingOccurrences(of: "'", with: "\\'") - .replacingOccurrences(of: "\\", with: "\\\\") - let escapedPass = password.replacingOccurrences(of: "'", with: "\\'") - .replacingOccurrences(of: "\\", with: "\\\\") - let escapedDb = database.replacingOccurrences(of: "'", with: "\\'") - .replacingOccurrences(of: "\\", with: "\\\\") + let escapedHost = escapeConnParam(host) + let escapedUser = escapeConnParam(user) + let escapedPass = escapeConnParam(password) + let escapedDb = escapeConnParam(database) let connStr = "host='\(escapedHost)' port='\(port)' dbname='\(escapedDb)' " + "user='\(escapedUser)' password='\(escapedPass)' connect_timeout='10' sslmode='disable'" @@ -298,6 +294,11 @@ private actor PostgreSQLActor { self.conn = connection } + private func escapeConnParam(_ value: String) -> String { + value.replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "'", with: "\\'") + } + func close() { if let conn { PQfinish(conn) diff --git a/TableProMobile/TableProMobile/Drivers/RedisDriver.swift b/TableProMobile/TableProMobile/Drivers/RedisDriver.swift index e44666c46..836bb6705 100644 --- a/TableProMobile/TableProMobile/Drivers/RedisDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/RedisDriver.swift @@ -371,6 +371,8 @@ private actor RedisActor { if database != 0 { let reply = try executeCommand(["SELECT", String(database)]) if case .error(let msg) = reply { + redisFree(context) + self.ctx = nil throw RedisError.connectionFailed("Failed to select database \(database): \(msg)") } } diff --git a/TableProMobile/TableProMobile/SSH/IOSSSHProvider.swift b/TableProMobile/TableProMobile/SSH/IOSSSHProvider.swift index d56b97c56..1e79854dc 100644 --- a/TableProMobile/TableProMobile/SSH/IOSSSHProvider.swift +++ b/TableProMobile/TableProMobile/SSH/IOSSSHProvider.swift @@ -48,13 +48,17 @@ final class IOSSSHProvider: SSHProvider, @unchecked Sendable { } func closeTunnel(for connectionId: UUID) async throws { + // IOSSSHProvider tracks tunnels by local port, not connectionId. + // Close the most recently created tunnel. This is correct for iOS + // where typically only one connection is active at a time. lock.lock() - let allTunnels = activeTunnels - activeTunnels.removeAll() + guard let (port, tunnel) = activeTunnels.first else { + lock.unlock() + return + } + activeTunnels.removeValue(forKey: port) lock.unlock() - for (_, tunnel) in allTunnels { - await tunnel.close() - } + await tunnel.close() } } diff --git a/TableProMobile/TableProMobile/SSH/SSHTunnel.swift b/TableProMobile/TableProMobile/SSH/SSHTunnel.swift index 6fb564519..bee47b6df 100644 --- a/TableProMobile/TableProMobile/SSH/SSHTunnel.swift +++ b/TableProMobile/TableProMobile/SSH/SSHTunnel.swift @@ -54,7 +54,7 @@ actor SSHTunnel { } let flags = fcntl(fd, F_GETFL, 0) - fcntl(fd, F_SETFL, flags | O_NONBLOCK) + _ = fcntl(fd, F_SETFL, flags | O_NONBLOCK) let connectResult = Darwin.connect(fd, addrInfo.pointee.ai_addr, addrInfo.pointee.ai_addrlen) @@ -88,7 +88,7 @@ actor SSHTunnel { } } - fcntl(fd, F_SETFL, flags) + _ = fcntl(fd, F_SETFL, flags) socketFD = fd Self.logger.debug("TCP connected to \(host):\(port)") @@ -401,7 +401,7 @@ actor SSHTunnel { if pollResult < 0 { break } // Channel -> Client - if pollFDs[1].revents & Int16(POLLIN) != 0 || pollResult == 0 { + if pollFDs[1].revents & Int16(POLLIN) != 0 { let readResult = Int(tablepro_libssh2_channel_read(channel, buffer, Self.bufferSize)) if readResult > 0 { var totalSent = 0 diff --git a/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift b/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift index 066175bfa..a8594a415 100644 --- a/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift +++ b/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift @@ -19,10 +19,10 @@ final class IOSSyncCoordinator { private var cachedRecords: [UUID: CKRecord] = [:] private func getEngine() -> CloudKitSyncEngine { - if engine == nil { - engine = CloudKitSyncEngine() - } - return engine! + if let engine { return engine } + let newEngine = CloudKitSyncEngine() + engine = newEngine + return newEngine } private var debounceTask: Task? @@ -31,7 +31,7 @@ final class IOSSyncCoordinator { // MARK: - Sync - func sync(localConnections: [DatabaseConnection]) async { + func sync(localConnections: [DatabaseConnection], isRetry: Bool = false) async { guard status != .syncing else { return } status = .syncing @@ -52,9 +52,13 @@ final class IOSSyncCoordinator { lastSyncDate = metadata.lastSyncDate status = .idle } catch let error as SyncError where error == .tokenExpired { + guard !isRetry else { + status = .error("Sync failed after token refresh") + return + } metadata.saveToken(nil) status = .idle - await sync(localConnections: localConnections) + await sync(localConnections: localConnections, isRetry: true) } catch { status = .error(error.localizedDescription) } diff --git a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift index 04dbdfe90..747dda713 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift @@ -356,26 +356,11 @@ struct ConnectionFormView: View { guard url.startAccessingSecurityScopedResource() else { return } defer { url.stopAccessingSecurityScopedResource() } - do { - let bookmarkData = try url.bookmarkData( - options: .minimalBookmark, - includingResourceValuesForKeys: nil, - relativeTo: nil - ) - let destURL = copyToDocuments(url) - selectedFileURL = destURL - database = destURL.path - if name.isEmpty { - name = destURL.deletingPathExtension().lastPathComponent - } - - BookmarkStore.save(bookmarkData, for: destURL.lastPathComponent) - } catch { - selectedFileURL = url - database = url.path - if name.isEmpty { - name = url.deletingPathExtension().lastPathComponent - } + let destURL = copyToDocuments(url) + selectedFileURL = destURL + database = destURL.path + if name.isEmpty { + name = destURL.deletingPathExtension().lastPathComponent } } @@ -470,6 +455,17 @@ struct ConnectionFormView: View { if sshEnabled { let secureStore = KeychainSecureStore() + + if let existing = existingConnection, + let oldSSH = existing.sshConfiguration { + let oldKey = "ssh-\(oldSSH.host)-\(oldSSH.username)" + let newKey = "ssh-\(sshHost)-\(sshUsername)" + if oldKey != newKey { + try? secureStore.delete(forKey: oldKey) + try? secureStore.delete(forKey: "ssh-key-\(oldSSH.host)-\(oldSSH.username)") + } + } + if !sshPassword.isEmpty { try? secureStore.store(sshPassword, forKey: "ssh-\(sshHost)-\(sshUsername)") } @@ -487,26 +483,3 @@ private struct TestResult { let message: String } -// MARK: - Bookmark Storage - -enum BookmarkStore { - private static let key = "com.TablePro.Mobile.bookmarks" - - static func save(_ data: Data, for filename: String) { - var bookmarks = loadAll() - bookmarks[filename] = data - UserDefaults.standard.set(try? JSONEncoder().encode(bookmarks), forKey: key) - } - - static func load(for filename: String) -> Data? { - loadAll()[filename] - } - - private static func loadAll() -> [String: Data] { - guard let data = UserDefaults.standard.data(forKey: key), - let dict = try? JSONDecoder().decode([String: Data].self, from: data) else { - return [:] - } - return dict - } -} diff --git a/TableProMobile/TableProMobile/Views/InsertRowView.swift b/TableProMobile/TableProMobile/Views/InsertRowView.swift index a0289a425..96f48236f 100644 --- a/TableProMobile/TableProMobile/Views/InsertRowView.swift +++ b/TableProMobile/TableProMobile/Views/InsertRowView.swift @@ -117,7 +117,7 @@ struct InsertRowView: View { .onAppear { values = Array(repeating: "", count: columnDetails.count) isNullFlags = columnDetails.map { col in - col.isPrimaryKey + col.isPrimaryKey && isAutoIncrement(col) } } .alert("Error", isPresented: $showOperationError) { diff --git a/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/TableProMobile/TableProMobile/Views/QueryEditorView.swift index 2ff0978d8..96e40bf2d 100644 --- a/TableProMobile/TableProMobile/Views/QueryEditorView.swift +++ b/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -264,10 +264,10 @@ struct QueryEditorView: View { } private func columnWidth(for columnIndex: Int, column: ColumnInfo, rows: [[String?]]) -> CGFloat { - let headerWidth = CGFloat(column.name.count) * 8 + 16 + let headerWidth = CGFloat((column.name as NSString).length) * 8 + 16 let maxDataWidth = rows.prefix(20).compactMap { row -> CGFloat? in guard columnIndex < row.count, let value = row[columnIndex] else { return nil } - return min(CGFloat(value.count) * 7.5, 200) + 16 + return min(CGFloat((value as NSString).length) * 7.5, 200) + 16 }.max() ?? 60 return max(max(headerWidth, maxDataWidth), 60) } From 034d4c8b0f0f67c406a729d226526c3f5dd6649a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 3 Apr 2026 16:04:43 +0700 Subject: [PATCH 49/61] fix: replace NSLock with actor in IOSSSHProvider, move SyncError Equatable to package --- .../Sources/TableProSync/SyncError.swift | 2 +- .../TableProMobile/SSH/IOSSSHProvider.swift | 38 +++++++++---------- .../Sync/IOSSyncCoordinator.swift | 9 ----- 3 files changed, 19 insertions(+), 30 deletions(-) diff --git a/Packages/TableProCore/Sources/TableProSync/SyncError.swift b/Packages/TableProCore/Sources/TableProSync/SyncError.swift index 71fbdc09a..65c21d367 100644 --- a/Packages/TableProCore/Sources/TableProSync/SyncError.swift +++ b/Packages/TableProCore/Sources/TableProSync/SyncError.swift @@ -1,6 +1,6 @@ import Foundation -public enum SyncError: Error, LocalizedError, Sendable { +public enum SyncError: Error, LocalizedError, Equatable, Sendable { case noAccount case networkUnavailable case zoneCreationFailed(String) diff --git a/TableProMobile/TableProMobile/SSH/IOSSSHProvider.swift b/TableProMobile/TableProMobile/SSH/IOSSSHProvider.swift index 1e79854dc..9c4a37b26 100644 --- a/TableProMobile/TableProMobile/SSH/IOSSSHProvider.swift +++ b/TableProMobile/TableProMobile/SSH/IOSSSHProvider.swift @@ -2,16 +2,13 @@ // IOSSSHProvider.swift // TableProMobile // -// SSHProvider implementation for iOS using libssh2. -// import Foundation import TableProDatabase import TableProModels final class IOSSSHProvider: SSHProvider, @unchecked Sendable { - private let lock = NSLock() - private var activeTunnels: [Int: SSHTunnel] = [:] + private let tunnelStore = TunnelStore() private let secureStore: SecureStore init(secureStore: SecureStore) { @@ -24,7 +21,7 @@ final class IOSSSHProvider: SSHProvider, @unchecked Sendable { remotePort: Int ) async throws -> TableProDatabase.SSHTunnel { let sshPassword = try? secureStore.retrieve(forKey: "ssh-\(config.host)-\(config.username)") - let keyPassphrase: String? = if config.privateKeyPath != nil { + let keyPassphrase: String? = if config.privateKeyPath != nil || config.privateKeyData != nil { try? secureStore.retrieve(forKey: "ssh-key-\(config.host)-\(config.username)") } else { nil @@ -39,26 +36,27 @@ final class IOSSSHProvider: SSHProvider, @unchecked Sendable { ) let port = await tunnel.port - - lock.lock() - activeTunnels[port] = tunnel - lock.unlock() + await tunnelStore.add(tunnel, port: port) return TableProDatabase.SSHTunnel(localHost: "127.0.0.1", localPort: port) } func closeTunnel(for connectionId: UUID) async throws { - // IOSSSHProvider tracks tunnels by local port, not connectionId. - // Close the most recently created tunnel. This is correct for iOS - // where typically only one connection is active at a time. - lock.lock() - guard let (port, tunnel) = activeTunnels.first else { - lock.unlock() - return - } - activeTunnels.removeValue(forKey: port) - lock.unlock() - + guard let tunnel = await tunnelStore.removeFirst() else { return } await tunnel.close() } } + +private actor TunnelStore { + var tunnels: [Int: SSHTunnel] = [:] + + func add(_ tunnel: SSHTunnel, port: Int) { + tunnels[port] = tunnel + } + + func removeFirst() -> SSHTunnel? { + guard let (port, tunnel) = tunnels.first else { return nil } + tunnels.removeValue(forKey: port) + return tunnel + } +} diff --git a/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift b/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift index a8594a415..dd1846762 100644 --- a/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift +++ b/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift @@ -158,12 +158,3 @@ final class IOSSyncCoordinator { } } -// SyncError Equatable for token expiry check -extension SyncError: Equatable { - public static func == (lhs: SyncError, rhs: SyncError) -> Bool { - switch (lhs, rhs) { - case (.tokenExpired, .tokenExpired): return true - default: return false - } - } -} From 9098e260b305d22d10414add3773b9d7ea0ce765 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 3 Apr 2026 16:20:12 +0700 Subject: [PATCH 50/61] =?UTF-8?q?feat:=20shared=20iCloud=20Keychain=20?= =?UTF-8?q?=E2=80=94=20access=20group=20+=20sync=20for=20cross-device=20pa?= =?UTF-8?q?ssword=20sharing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TablePro/Core/Storage/KeychainHelper.swift | 5 ++ TablePro/TablePro.entitlements | 4 ++ .../Platform/KeychainSecureStore.swift | 50 +++++++++++++++++-- .../TableProMobileRelease.entitlements | 4 ++ 4 files changed, 58 insertions(+), 5 deletions(-) diff --git a/TablePro/Core/Storage/KeychainHelper.swift b/TablePro/Core/Storage/KeychainHelper.swift index 2bd9ac2d2..5825cf1cb 100644 --- a/TablePro/Core/Storage/KeychainHelper.swift +++ b/TablePro/Core/Storage/KeychainHelper.swift @@ -11,6 +11,7 @@ final class KeychainHelper { static let shared = KeychainHelper() private let service = "com.TablePro" + private let accessGroup = "D7HJ5TFYCU.com.TablePro.shared" private static let logger = Logger(subsystem: "com.TablePro", category: "KeychainHelper") private static let migrationKey = "com.TablePro.keychainMigratedToDataProtection" static let passwordSyncEnabledKey = "com.TablePro.keychainPasswordSyncEnabled" @@ -31,6 +32,7 @@ final class KeychainHelper { kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: key, + kSecAttrAccessGroup as String: accessGroup, kSecValueData as String: data, kSecUseDataProtectionKeychain as String: true, kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock @@ -47,6 +49,7 @@ final class KeychainHelper { kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: key, + kSecAttrAccessGroup as String: accessGroup, kSecUseDataProtectionKeychain as String: true, kSecAttrSynchronizable as String: kSecAttrSynchronizableAny ] @@ -69,6 +72,7 @@ final class KeychainHelper { kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: key, + kSecAttrAccessGroup as String: accessGroup, kSecUseDataProtectionKeychain as String: true, kSecAttrSynchronizable as String: kSecAttrSynchronizableAny, kSecReturnData as String: true, @@ -93,6 +97,7 @@ final class KeychainHelper { kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: key, + kSecAttrAccessGroup as String: accessGroup, kSecUseDataProtectionKeychain as String: true, kSecAttrSynchronizable as String: kSecAttrSynchronizableAny ] diff --git a/TablePro/TablePro.entitlements b/TablePro/TablePro.entitlements index 29dfe2f23..bf5b4c574 100644 --- a/TablePro/TablePro.entitlements +++ b/TablePro/TablePro.entitlements @@ -18,5 +18,9 @@ CloudKit + keychain-access-groups + + $(AppIdentifierPrefix)com.TablePro.shared + diff --git a/TableProMobile/TableProMobile/Platform/KeychainSecureStore.swift b/TableProMobile/TableProMobile/Platform/KeychainSecureStore.swift index 7bbb022f5..cd691b7d2 100644 --- a/TableProMobile/TableProMobile/Platform/KeychainSecureStore.swift +++ b/TableProMobile/TableProMobile/Platform/KeychainSecureStore.swift @@ -2,25 +2,29 @@ // KeychainSecureStore.swift // TableProMobile // -// iOS Keychain implementation for SecureStore protocol. -// Uses Security.framework (works on both macOS and iOS). -// import Foundation import Security import TableProDatabase final class KeychainSecureStore: SecureStore { - private let serviceName = "com.TablePro.Mobile" + private let serviceName = "com.TablePro" + private let accessGroup = "D7HJ5TFYCU.com.TablePro.shared" + + init() { + migrateFromOldServiceIfNeeded() + } func store(_ value: String, forKey key: String) throws { guard let data = value.data(using: .utf8) else { return } - // Delete existing item first let deleteQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: serviceName, kSecAttrAccount as String: key, + kSecAttrAccessGroup as String: accessGroup, + kSecAttrSynchronizable as String: kSecAttrSynchronizableAny, + kSecUseDataProtectionKeychain as String: true, ] SecItemDelete(deleteQuery as CFDictionary) @@ -28,8 +32,11 @@ final class KeychainSecureStore: SecureStore { kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: serviceName, kSecAttrAccount as String: key, + kSecAttrAccessGroup as String: accessGroup, kSecValueData as String: data, kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, + kSecAttrSynchronizable as String: true, + kSecUseDataProtectionKeychain as String: true, ] let status = SecItemAdd(addQuery as CFDictionary, nil) if status != errSecSuccess { @@ -42,8 +49,11 @@ final class KeychainSecureStore: SecureStore { kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: serviceName, kSecAttrAccount as String: key, + kSecAttrAccessGroup as String: accessGroup, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne, + kSecAttrSynchronizable as String: kSecAttrSynchronizableAny, + kSecUseDataProtectionKeychain as String: true, ] var result: AnyObject? @@ -63,12 +73,42 @@ final class KeychainSecureStore: SecureStore { kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: serviceName, kSecAttrAccount as String: key, + kSecAttrAccessGroup as String: accessGroup, + kSecAttrSynchronizable as String: kSecAttrSynchronizableAny, + kSecUseDataProtectionKeychain as String: true, ] let status = SecItemDelete(query as CFDictionary) if status != errSecSuccess && status != errSecItemNotFound { throw KeychainError.deleteFailed(status) } } + + // MARK: - Migration + + private func migrateFromOldServiceIfNeeded() { + let migrationKey = "com.TablePro.Mobile.keychainMigrated" + guard !UserDefaults.standard.bool(forKey: migrationKey) else { return } + defer { UserDefaults.standard.set(true, forKey: migrationKey) } + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "com.TablePro.Mobile", + kSecReturnAttributes as String: true, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitAll, + ] + + var result: AnyObject? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let items = result as? [[String: Any]] else { return } + + for item in items { + guard let account = item[kSecAttrAccount as String] as? String, + let data = item[kSecValueData as String] as? Data, + let value = String(data: data, encoding: .utf8) else { continue } + try? store(value, forKey: account) + } + } } enum KeychainError: Error, LocalizedError { diff --git a/TableProMobile/TableProMobile/TableProMobileRelease.entitlements b/TableProMobile/TableProMobile/TableProMobileRelease.entitlements index cab2bbf54..ba457f12b 100644 --- a/TableProMobile/TableProMobile/TableProMobileRelease.entitlements +++ b/TableProMobile/TableProMobile/TableProMobileRelease.entitlements @@ -12,5 +12,9 @@ CloudKit + keychain-access-groups + + $(AppIdentifierPrefix)com.TablePro.shared + From 3e55ad6bf23eb373a9c821e7bb8c3f72fd829b53 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 3 Apr 2026 16:39:13 +0700 Subject: [PATCH 51/61] =?UTF-8?q?fix:=20align=20Keychain=20key=20patterns?= =?UTF-8?q?=20with=20macOS=20=E2=80=94=20com.TablePro.password/sshpassword?= =?UTF-8?q?/keypassphrase=20prefix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TableProDatabase/ConnectionManager.swift | 10 +++++++--- TablePro/TablePro.entitlements | 12 +++++------ TableProMobile/TableProMobile/AppState.swift | 2 ++ .../TableProMobile/SSH/IOSSSHProvider.swift | 20 +++++++++++++++---- .../TableProMobile/Views/ConnectedView.swift | 2 ++ .../Views/ConnectionFormView.swift | 17 ++++------------ 6 files changed, 37 insertions(+), 26 deletions(-) diff --git a/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift b/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift index c9ee10fa9..44d7102e1 100644 --- a/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift +++ b/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift @@ -20,7 +20,7 @@ public final class ConnectionManager: @unchecked Sendable { } public func connect(_ connection: DatabaseConnection) async throws -> ConnectionSession { - let password = try secureStore.retrieve(forKey: connection.id.uuidString) + let password = try secureStore.retrieve(forKey: Self.passwordKey(for: connection.id)) var effectiveHost = connection.host var effectivePort = connection.port @@ -62,11 +62,15 @@ public final class ConnectionManager: @unchecked Sendable { } public func storePassword(_ password: String, for connectionId: UUID) throws { - try secureStore.store(password, forKey: connectionId.uuidString) + try secureStore.store(password, forKey: Self.passwordKey(for: connectionId)) } public func deletePassword(for connectionId: UUID) throws { - try secureStore.delete(forKey: connectionId.uuidString) + try secureStore.delete(forKey: Self.passwordKey(for: connectionId)) + } + + private static func passwordKey(for connectionId: UUID) -> String { + "com.TablePro.password.\(connectionId.uuidString)" } public func disconnect(_ connectionId: UUID) async { diff --git a/TablePro/TablePro.entitlements b/TablePro/TablePro.entitlements index bf5b4c574..53635b71c 100644 --- a/TablePro/TablePro.entitlements +++ b/TablePro/TablePro.entitlements @@ -2,14 +2,8 @@ - com.apple.security.app-sandbox - - com.apple.security.cs.disable-library-validation - com.apple.application-identifier D7HJ5TFYCU.com.TablePro - com.apple.developer.team-identifier - D7HJ5TFYCU com.apple.developer.icloud-container-identifiers iCloud.com.TablePro @@ -18,6 +12,12 @@ CloudKit + com.apple.developer.team-identifier + D7HJ5TFYCU + com.apple.security.app-sandbox + + com.apple.security.cs.disable-library-validation + keychain-access-groups $(AppIdentifierPrefix)com.TablePro.shared diff --git a/TableProMobile/TableProMobile/AppState.swift b/TableProMobile/TableProMobile/AppState.swift index 0a9543f38..b756dd426 100644 --- a/TableProMobile/TableProMobile/AppState.swift +++ b/TableProMobile/TableProMobile/AppState.swift @@ -13,6 +13,7 @@ final class AppState { var connections: [DatabaseConnection] = [] let connectionManager: ConnectionManager let syncCoordinator = IOSSyncCoordinator() + let sshProvider: IOSSSHProvider private let storage = ConnectionPersistence() @@ -20,6 +21,7 @@ final class AppState { let driverFactory = IOSDriverFactory() let secureStore = KeychainSecureStore() let sshProvider = IOSSSHProvider(secureStore: secureStore) + self.sshProvider = sshProvider self.connectionManager = ConnectionManager( driverFactory: driverFactory, secureStore: secureStore, diff --git a/TableProMobile/TableProMobile/SSH/IOSSSHProvider.swift b/TableProMobile/TableProMobile/SSH/IOSSSHProvider.swift index 9c4a37b26..ae354d781 100644 --- a/TableProMobile/TableProMobile/SSH/IOSSSHProvider.swift +++ b/TableProMobile/TableProMobile/SSH/IOSSSHProvider.swift @@ -11,6 +11,9 @@ final class IOSSSHProvider: SSHProvider, @unchecked Sendable { private let tunnelStore = TunnelStore() private let secureStore: SecureStore + /// Set by caller before createTunnel to enable connectionId-based Keychain lookup + var pendingConnectionId: UUID? + init(secureStore: SecureStore) { self.secureStore = secureStore } @@ -20,13 +23,22 @@ final class IOSSSHProvider: SSHProvider, @unchecked Sendable { remoteHost: String, remotePort: Int ) async throws -> TableProDatabase.SSHTunnel { - let sshPassword = try? secureStore.retrieve(forKey: "ssh-\(config.host)-\(config.username)") - let keyPassphrase: String? = if config.privateKeyPath != nil || config.privateKeyData != nil { - try? secureStore.retrieve(forKey: "ssh-key-\(config.host)-\(config.username)") + // Resolve SSH credentials using macOS-compatible Keychain keys + let sshPassword: String? + let keyPassphrase: String? + + if let connId = pendingConnectionId { + sshPassword = try? secureStore.retrieve( + forKey: "com.TablePro.sshpassword.\(connId.uuidString)") + keyPassphrase = try? secureStore.retrieve( + forKey: "com.TablePro.keypassphrase.\(connId.uuidString)") } else { - nil + sshPassword = nil + keyPassphrase = nil } + pendingConnectionId = nil + let tunnel = try await SSHTunnelFactory.create( config: config, remoteHost: remoteHost, diff --git a/TableProMobile/TableProMobile/Views/ConnectedView.swift b/TableProMobile/TableProMobile/Views/ConnectedView.swift index 5a6fbccb0..778bd84a4 100644 --- a/TableProMobile/TableProMobile/Views/ConnectedView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectedView.swift @@ -92,6 +92,8 @@ struct ConnectedView: View { isConnecting = true errorMessage = nil + appState.sshProvider.pendingConnectionId = connection.id + do { let session = try await appState.connectionManager.connect(connection) self.session = session diff --git a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift index 747dda713..9f05fa709 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift @@ -403,10 +403,10 @@ struct ConnectionFormView: View { let secureStore = KeychainSecureStore() if sshEnabled && !sshPassword.isEmpty { - try? secureStore.store(sshPassword, forKey: "ssh-\(sshHost)-\(sshUsername)") + try? secureStore.store(sshPassword, forKey: "com.TablePro.sshpassword.\(connection.id.uuidString)") } if sshEnabled && !sshKeyPassphrase.isEmpty { - try? secureStore.store(sshKeyPassphrase, forKey: "ssh-key-\(sshHost)-\(sshUsername)") + try? secureStore.store(sshKeyPassphrase, forKey: "com.TablePro.keypassphrase.\(connection.id.uuidString)") } do { @@ -456,21 +456,12 @@ struct ConnectionFormView: View { if sshEnabled { let secureStore = KeychainSecureStore() - if let existing = existingConnection, - let oldSSH = existing.sshConfiguration { - let oldKey = "ssh-\(oldSSH.host)-\(oldSSH.username)" - let newKey = "ssh-\(sshHost)-\(sshUsername)" - if oldKey != newKey { - try? secureStore.delete(forKey: oldKey) - try? secureStore.delete(forKey: "ssh-key-\(oldSSH.host)-\(oldSSH.username)") - } - } if !sshPassword.isEmpty { - try? secureStore.store(sshPassword, forKey: "ssh-\(sshHost)-\(sshUsername)") + try? secureStore.store(sshPassword, forKey: "com.TablePro.sshpassword.\(connection.id.uuidString)") } if !sshKeyPassphrase.isEmpty { - try? secureStore.store(sshKeyPassphrase, forKey: "ssh-key-\(sshHost)-\(sshUsername)") + try? secureStore.store(sshKeyPassphrase, forKey: "com.TablePro.keypassphrase.\(connection.id.uuidString)") } } From d353ddf12924b89363e9a5ec0a5972f7eb6c90c6 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 3 Apr 2026 17:08:07 +0700 Subject: [PATCH 52/61] fix: use tempId for SSH Keychain keys in testConnection, set pendingConnectionId --- .../TableProMobile/Views/ConnectionFormView.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift index 9f05fa709..7417f5b38 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift @@ -403,12 +403,14 @@ struct ConnectionFormView: View { let secureStore = KeychainSecureStore() if sshEnabled && !sshPassword.isEmpty { - try? secureStore.store(sshPassword, forKey: "com.TablePro.sshpassword.\(connection.id.uuidString)") + try? secureStore.store(sshPassword, forKey: "com.TablePro.sshpassword.\(tempId.uuidString)") } if sshEnabled && !sshKeyPassphrase.isEmpty { - try? secureStore.store(sshKeyPassphrase, forKey: "com.TablePro.keypassphrase.\(connection.id.uuidString)") + try? secureStore.store(sshKeyPassphrase, forKey: "com.TablePro.keypassphrase.\(tempId.uuidString)") } + appState.sshProvider.pendingConnectionId = tempId + do { _ = try await appState.connectionManager.connect(testConn) await appState.connectionManager.disconnect(tempId) @@ -418,6 +420,8 @@ struct ConnectionFormView: View { } try? appState.connectionManager.deletePassword(for: tempId) + try? secureStore.delete(forKey: "com.TablePro.sshpassword.\(tempId.uuidString)") + try? secureStore.delete(forKey: "com.TablePro.keypassphrase.\(tempId.uuidString)") isTesting = false } From 778369fdc73a7685e2a68259406fffd7c959fa43 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 3 Apr 2026 17:47:58 +0700 Subject: [PATCH 53/61] =?UTF-8?q?fix:=20handle=20CloudKit=20deletions=20in?= =?UTF-8?q?=20sync=20=E2=80=94=20remove=20connections=20deleted=20on=20mac?= =?UTF-8?q?OS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sync/IOSSyncCoordinator.swift | 37 +++++++++++++------ 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift b/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift index dd1846762..adaa11ead 100644 --- a/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift +++ b/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift @@ -114,7 +114,12 @@ final class IOSSyncCoordinator { // MARK: - Pull - private func pull() async throws -> [DatabaseConnection] { + private struct PullChanges { + var changed: [DatabaseConnection] = [] + var deletedIDs: Set = [] + } + + private func pull() async throws -> PullChanges { let token = metadata.loadToken() let result = try await getEngine().pull(since: token) @@ -122,34 +127,44 @@ final class IOSSyncCoordinator { metadata.saveToken(newToken) } - var connections: [DatabaseConnection] = [] + var changes = PullChanges() for record in result.changedRecords { if record.recordType == SyncRecordType.connection.rawValue { if let connection = SyncRecordMapper.toConnection(record) { cachedRecords[connection.id] = record - connections.append(connection) + changes.changed.append(connection) } } } - return connections + for recordID in result.deletedRecordIDs { + let name = recordID.recordName + if name.hasPrefix("Connection_") { + let uuidStr = String(name.dropFirst("Connection_".count)) + if let uuid = UUID(uuidString: uuidStr) { + changes.deletedIDs.insert(uuid) + } + } + } + + return changes } // MARK: - Merge (last-write-wins) - private func merge(local: [DatabaseConnection], remote: [DatabaseConnection]) -> [DatabaseConnection] { - var result = local - let localMap = Dictionary(uniqueKeysWithValues: local.map { ($0.id, $0) }) + private func merge(local: [DatabaseConnection], remote: PullChanges) -> [DatabaseConnection] { + // Remove deleted connections + var result = local.filter { !remote.deletedIDs.contains($0.id) } + + let localMap = Dictionary(uniqueKeysWithValues: result.map { ($0.id, $0) }) - for remoteConn in remote { + for remoteConn in remote.changed { if localMap[remoteConn.id] != nil { - // Exists locally — replace with server version (last-write-wins) if let index = result.firstIndex(where: { $0.id == remoteConn.id }) { result[index] = remoteConn } - } else { - // New from server + } else if !remote.deletedIDs.contains(remoteConn.id) { result.append(remoteConn) } } From 311cdd0666ba64731a7357fbf44e8f7771b24c73 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 3 Apr 2026 17:58:43 +0700 Subject: [PATCH 54/61] =?UTF-8?q?fix:=2010=20sync=20fixes=20=E2=80=94=20to?= =?UTF-8?q?ken=20expiry=20detection,=20pull-before-push,=20atomic=20push,?= =?UTF-8?q?=20SSH=20cleanup,=20color=20deletion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/TableProSync/CloudKitSyncEngine.swift | 11 +++++++++-- .../Sources/TableProSync/SyncMetadataStorage.swift | 3 ++- .../Sources/TableProSync/SyncRecordMapper.swift | 13 +++++++++++++ TableProMobile/TableProMobile/AppState.swift | 3 +++ .../TableProMobile/Sync/IOSSyncCoordinator.swift | 4 ++-- .../TableProMobile/TableProMobileApp.swift | 4 +++- 6 files changed, 32 insertions(+), 6 deletions(-) diff --git a/Packages/TableProCore/Sources/TableProSync/CloudKitSyncEngine.swift b/Packages/TableProCore/Sources/TableProSync/CloudKitSyncEngine.swift index 2c48c62aa..b6394a47a 100644 --- a/Packages/TableProCore/Sources/TableProSync/CloudKitSyncEngine.swift +++ b/Packages/TableProCore/Sources/TableProSync/CloudKitSyncEngine.swift @@ -81,7 +81,7 @@ public actor CloudKitSyncEngine { ) // .changedKeys overwrites only the fields we set, safe for partial updates operation.savePolicy = .changedKeys - operation.isAtomic = false + operation.isAtomic = true return try await withCheckedThrowingContinuation { continuation in operation.perRecordSaveBlock = { recordID, result in @@ -148,6 +148,8 @@ public actor CloudKitSyncEngine { newToken = serverToken case .failure(let error): Self.logger.warning("Zone fetch result error: \(error.localizedDescription)") + // Zone-level failure with records collected so far is acceptable — + // newToken stays nil, forcing a full re-fetch on next sync cycle. } } @@ -160,7 +162,12 @@ public actor CloudKitSyncEngine { newToken: newToken )) case .failure(let error): - continuation.resume(throwing: error) + // Map CKError.changeTokenExpired to SyncError.tokenExpired + if let ckError = error as? CKError, ckError.code == .changeTokenExpired { + continuation.resume(throwing: SyncError.tokenExpired) + } else { + continuation.resume(throwing: error) + } } } diff --git a/Packages/TableProCore/Sources/TableProSync/SyncMetadataStorage.swift b/Packages/TableProCore/Sources/TableProSync/SyncMetadataStorage.swift index ae68be863..b9805608a 100644 --- a/Packages/TableProCore/Sources/TableProSync/SyncMetadataStorage.swift +++ b/Packages/TableProCore/Sources/TableProSync/SyncMetadataStorage.swift @@ -12,7 +12,8 @@ public struct Tombstone: Codable, Sendable { } } -public final class SyncMetadataStorage: Sendable { +@MainActor +public final class SyncMetadataStorage { private static let logger = Logger(subsystem: "com.TablePro", category: "SyncMetadataStorage") private let defaults: UserDefaults diff --git a/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift b/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift index 02aa431c6..c24ae8e8a 100644 --- a/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift +++ b/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift @@ -175,14 +175,21 @@ public enum SyncRecordMapper { if let colorTag = connection.colorTag { record["color"] = colorTag as CKRecordValue record["colorTag"] = colorTag as CKRecordValue + } else { + record["color"] = nil + record["colorTag"] = nil } if let groupId = connection.groupId { record["groupId"] = groupId.uuidString as CKRecordValue + } else { + record["groupId"] = nil } if let queryTimeout = connection.queryTimeoutSeconds { record["queryTimeoutSeconds"] = Int64(queryTimeout) as CKRecordValue + } else { + record["queryTimeoutSeconds"] = nil } if let sshConfig = connection.sshConfiguration { @@ -191,18 +198,24 @@ public enum SyncRecordMapper { if let data = try? encoder.encode(syncSafe) { record["sshConfigJson"] = data as CKRecordValue } + } else { + record["sshConfigJson"] = nil } if let sslConfig = connection.sslConfiguration { if let data = try? encoder.encode(sslConfig) { record["sslConfigJson"] = data as CKRecordValue } + } else { + record["sslConfigJson"] = nil } if !connection.additionalFields.isEmpty { if let data = try? encoder.encode(connection.additionalFields) { record["additionalFieldsJson"] = data as CKRecordValue } + } else { + record["additionalFieldsJson"] = nil } record["modifiedAtLocal"] = Date() as CKRecordValue diff --git a/TableProMobile/TableProMobile/AppState.swift b/TableProMobile/TableProMobile/AppState.swift index b756dd426..99be53bb5 100644 --- a/TableProMobile/TableProMobile/AppState.swift +++ b/TableProMobile/TableProMobile/AppState.swift @@ -55,6 +55,9 @@ final class AppState { func removeConnection(_ connection: DatabaseConnection) { connections.removeAll { $0.id == connection.id } try? connectionManager.deletePassword(for: connection.id) + let secureStore = KeychainSecureStore() + try? secureStore.delete(forKey: "com.TablePro.sshpassword.\(connection.id.uuidString)") + try? secureStore.delete(forKey: "com.TablePro.keypassphrase.\(connection.id.uuidString)") storage.save(connections) syncCoordinator.markDeleted(connection.id) syncCoordinator.scheduleSyncAfterChange(localConnections: connections) diff --git a/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift b/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift index adaa11ead..96224deff 100644 --- a/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift +++ b/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift @@ -43,9 +43,9 @@ final class IOSSyncCoordinator { } try await getEngine().ensureZoneExists() + let remoteChanges = try await pull() try await push(localConnections: localConnections) - let remoteConnections = try await pull() - let merged = merge(local: localConnections, remote: remoteConnections) + let merged = merge(local: localConnections, remote: remoteChanges) onConnectionsChanged?(merged) metadata.lastSyncDate = Date() diff --git a/TableProMobile/TableProMobile/TableProMobileApp.swift b/TableProMobile/TableProMobile/TableProMobileApp.swift index b3cb669b3..eb62fdefe 100644 --- a/TableProMobile/TableProMobile/TableProMobileApp.swift +++ b/TableProMobile/TableProMobile/TableProMobileApp.swift @@ -10,6 +10,7 @@ import TableProModels @main struct TableProMobileApp: App { @State private var appState = AppState() + @State private var syncTask: Task? @Environment(\.scenePhase) private var scenePhase var body: some Scene { @@ -20,7 +21,8 @@ struct TableProMobileApp: App { .onChange(of: scenePhase) { _, phase in switch phase { case .active: - Task { await appState.syncCoordinator.sync(localConnections: appState.connections) } + syncTask?.cancel() + syncTask = Task { await appState.syncCoordinator.sync(localConnections: appState.connections) } case .background: Task { await appState.connectionManager.disconnectAll() } default: From c1405fbec4dcd3d5af1275527ecfa87712d1b8a4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 3 Apr 2026 18:02:57 +0700 Subject: [PATCH 55/61] fix: wrap NavigationSplitView detail in NavigationStack for push navigation --- .../Views/ConnectionListView.swift | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/TableProMobile/TableProMobile/Views/ConnectionListView.swift b/TableProMobile/TableProMobile/Views/ConnectionListView.swift index a603187de..37a6f5281 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionListView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionListView.swift @@ -31,14 +31,16 @@ struct ConnectionListView: View { } } } detail: { - if let connection = selectedConnection { - ConnectedView(connection: connection) - } else { - ContentUnavailableView( - "Select a Connection", - systemImage: "server.rack", - description: Text("Choose a connection from the sidebar.") - ) + NavigationStack { + if let connection = selectedConnection { + ConnectedView(connection: connection) + } else { + ContentUnavailableView( + "Select a Connection", + systemImage: "server.rack", + description: Text("Choose a connection from the sidebar.") + ) + } } } .sheet(isPresented: $showingAddConnection) { From bebb7c3be192ddcbd1e590ead4aa3e7c7c539efc Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 3 Apr 2026 18:06:25 +0700 Subject: [PATCH 56/61] fix: guard SSH channel close with isAlive check to prevent use-after-free crash --- TableProMobile/TableProMobile/SSH/SSHTunnel.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/TableProMobile/TableProMobile/SSH/SSHTunnel.swift b/TableProMobile/TableProMobile/SSH/SSHTunnel.swift index bee47b6df..1c212e05b 100644 --- a/TableProMobile/TableProMobile/SSH/SSHTunnel.swift +++ b/TableProMobile/TableProMobile/SSH/SSHTunnel.swift @@ -387,8 +387,10 @@ actor SSHTunnel { defer { buffer.deallocate() Darwin.close(clientFD) - libssh2_channel_close(channel) - libssh2_channel_free(channel) + if isAlive, session != nil { + libssh2_channel_close(channel) + libssh2_channel_free(channel) + } } while isAlive { From fc1104eee68496973dbdb32fe69dd579e26e1035 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 3 Apr 2026 18:09:49 +0700 Subject: [PATCH 57/61] feat: add pull-to-refresh on connection list to trigger iCloud sync --- TableProMobile/TableProMobile/Views/ConnectionListView.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/TableProMobile/TableProMobile/Views/ConnectionListView.swift b/TableProMobile/TableProMobile/Views/ConnectionListView.swift index 37a6f5281..a573576c3 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionListView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionListView.swift @@ -116,6 +116,9 @@ struct ConnectionListView: View { } } .listStyle(.insetGrouped) + .refreshable { + await appState.syncCoordinator.sync(localConnections: appState.connections) + } } } } From 1c217b2a675b3f940c22dfc9f39985b89b1c3d71 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 3 Apr 2026 18:10:40 +0700 Subject: [PATCH 58/61] fix: add Sync from iCloud button on empty connection list --- .../TableProMobile/Views/ConnectionListView.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/TableProMobile/TableProMobile/Views/ConnectionListView.swift b/TableProMobile/TableProMobile/Views/ConnectionListView.swift index a573576c3..78272117f 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionListView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionListView.swift @@ -63,12 +63,16 @@ struct ConnectionListView: View { ContentUnavailableView { Label("No Connections", systemImage: "server.rack") } description: { - Text("Add a database connection to get started.") + Text("Add a database connection or pull to sync from iCloud.") } actions: { Button("Add Connection") { showingAddConnection = true } .buttonStyle(.borderedProminent) + + Button("Sync from iCloud") { + Task { await appState.syncCoordinator.sync(localConnections: appState.connections) } + } } } else { List(selection: $selectedConnection) { From 2032bcfe9e2ebc1abe00deaa6c63db0d0b1296bd Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 3 Apr 2026 18:12:06 +0700 Subject: [PATCH 59/61] =?UTF-8?q?refactor:=20native=20iOS=20sync=20UX=20?= =?UTF-8?q?=E2=80=94=20toolbar=20cloud=20button,=20loading=20state,=20pull?= =?UTF-8?q?-to-refresh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Views/ConnectionListView.swift | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/TableProMobile/TableProMobile/Views/ConnectionListView.swift b/TableProMobile/TableProMobile/Views/ConnectionListView.swift index 78272117f..2044f04a5 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionListView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionListView.swift @@ -17,6 +17,10 @@ struct ConnectionListView: View { return grouped.sorted { $0.key < $1.key } } + private var isSyncing: Bool { + appState.syncCoordinator.status == .syncing + } + var body: some View { NavigationSplitView { sidebar @@ -29,6 +33,21 @@ struct ConnectionListView: View { Image(systemName: "plus") } } + ToolbarItem(placement: .topBarLeading) { + if isSyncing { + ProgressView() + .controlSize(.small) + } else { + Button { + Task { + await appState.syncCoordinator.sync( + localConnections: appState.connections) + } + } label: { + Image(systemName: "arrow.triangle.2.circlepath.icloud") + } + } + } } } detail: { NavigationStack { @@ -59,21 +78,20 @@ struct ConnectionListView: View { @ViewBuilder private var sidebar: some View { - if appState.connections.isEmpty { + if appState.connections.isEmpty && !isSyncing { ContentUnavailableView { Label("No Connections", systemImage: "server.rack") } description: { - Text("Add a database connection or pull to sync from iCloud.") + Text("Add a database connection to get started.") } actions: { Button("Add Connection") { showingAddConnection = true } .buttonStyle(.borderedProminent) - - Button("Sync from iCloud") { - Task { await appState.syncCoordinator.sync(localConnections: appState.connections) } - } } + } else if appState.connections.isEmpty && isSyncing { + ProgressView("Syncing from iCloud...") + .frame(maxWidth: .infinity, maxHeight: .infinity) } else { List(selection: $selectedConnection) { ForEach(groupedConnections, id: \.0) { sectionTitle, connections in From 1093a74456a49a365e5357881367bb7ebf769ab9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 3 Apr 2026 18:12:48 +0700 Subject: [PATCH 60/61] fix: add missing TableProSync import in ConnectionListView --- TableProMobile/TableProMobile/Views/ConnectionListView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/TableProMobile/TableProMobile/Views/ConnectionListView.swift b/TableProMobile/TableProMobile/Views/ConnectionListView.swift index 2044f04a5..268fd74c8 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionListView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionListView.swift @@ -5,6 +5,7 @@ import SwiftUI import TableProModels +import TableProSync struct ConnectionListView: View { @Environment(AppState.self) private var appState From 9067a54558ca02adde3a70b259210f2b4b802944 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 3 Apr 2026 18:21:58 +0700 Subject: [PATCH 61/61] fix: avoid Oracle LONG column type that crashes OracleNIO decoder --- Plugins/OracleDriverPlugin/OraclePlugin.swift | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/Plugins/OracleDriverPlugin/OraclePlugin.swift b/Plugins/OracleDriverPlugin/OraclePlugin.swift index cab5e4d38..35432d503 100644 --- a/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -276,7 +276,6 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { c.DATA_PRECISION, c.DATA_SCALE, c.NULLABLE, - c.DATA_DEFAULT, CASE WHEN cc.COLUMN_NAME IS NOT NULL THEN 'Y' ELSE 'N' END AS IS_PK FROM ALL_TAB_COLUMNS c LEFT JOIN ( @@ -300,8 +299,7 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { let precision = row[safe: 3] ?? nil let scale = row[safe: 4] ?? nil let isNullable = (row[safe: 5] ?? nil) == "Y" - let defaultValue = (row[safe: 6] ?? nil)?.trimmingCharacters(in: .whitespacesAndNewlines) - let isPk = (row[safe: 7] ?? nil) == "Y" + let isPk = (row[safe: 6] ?? nil) == "Y" let fullType = buildOracleFullType(dataType: dataType, dataLength: dataLength, precision: precision, scale: scale) @@ -310,7 +308,7 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { dataType: fullType, isNullable: isNullable, isPrimaryKey: isPk, - defaultValue: defaultValue + defaultValue: nil ) } } @@ -537,7 +535,9 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { func fetchViewDefinition(view: String, schema: String?) async throws -> String { let escapedView = view.replacingOccurrences(of: "'", with: "''") let escaped = effectiveSchemaEscaped(schema) - let sql = "SELECT TEXT FROM ALL_VIEWS WHERE VIEW_NAME = '\(escapedView)' AND OWNER = '\(escaped)'" + // Use DBMS_METADATA.GET_DDL instead of ALL_VIEWS.TEXT to avoid LONG column type + // that crashes OracleNIO's decoder + let sql = "SELECT DBMS_METADATA.GET_DDL('VIEW', '\(escapedView)', '\(escaped)') FROM DUAL" let result = try await execute(query: sql) return result.rows.first?.first?.flatMap { $0 } ?? "" } @@ -568,6 +568,19 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { comment: comment ) } + + // Fallback for views: ALL_TABLES returns no rows for views + let viewSQL = """ + SELECT tc.COMMENTS + FROM ALL_TAB_COMMENTS tc + WHERE tc.TABLE_NAME = '\(escapedTable)' AND tc.OWNER = '\(escaped)' + """ + let viewResult = try await execute(query: viewSQL) + if let row = viewResult.rows.first { + let comment = row[safe: 0] ?? nil + return PluginTableMetadata(tableName: table, comment: comment) + } + return PluginTableMetadata(tableName: table) }