diff --git a/CHANGELOG.md b/CHANGELOG.md index 10612001..f917a931 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add database URL scheme support — open connections directly from terminal with `open "mysql://user@host/db" -a TablePro` (supports MySQL, PostgreSQL, SQLite, MongoDB, Redis, MSSQL) +- Oracle Database support via OCI (Oracle Call Interface) +- Add database URL scheme support — open connections directly from terminal with `open "mysql://user@host/db" -a TablePro` (supports MySQL, PostgreSQL, SQLite, MongoDB, Redis, MSSQL, Oracle) - SSH Agent authentication method for SSH tunnels (compatible with 1Password SSH Agent, Secretive, ssh-agent) ### Changed @@ -18,6 +19,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Fix memory leak where session state objects were recreated on every tab open due to SwiftUI `@State` init trap, causing 785MB usage at 5 tabs with 734MB retained after closing +- Fix per-cell field editor allocation in DataGrid creating 180+ NSTextView instances instead of sharing one +- Fix NSEvent monitor not removed on all popover dismissal paths in connection switcher +- Fix race condition in FreeTDS `disconnect()` where `dbproc` was set to nil without holding the lock +- Fix data race in `MainContentCoordinator.deinit` reading `nonisolated(unsafe)` flags from arbitrary threads +- Fix JSON encoding and file I/O blocking the main thread in TabStateStorage - Fix MySQL/MariaDB getting `BEGIN` instead of `START TRANSACTION` in table operations and SQL preview - Fix port resetting to default value when editing a connection with a custom port - Replace `.onTapGesture` with `Button` in color pickers, section headers, group headers, and connection switcher for VoiceOver accessibility diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 6afd816a..b60e8dca 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 5ACE00012F4F000000000006 /* CodeEditTextView in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000007 /* CodeEditTextView */; }; 5ACE00012F4F00000000000A /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000009 /* Sparkle */; }; 5ACE00012F4F00000000000D /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F00000000000C /* MarkdownUI */; }; + 5AEE5B362F5C9B7B00FA84D7 /* OracleNIO in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F00000000000F /* OracleNIO */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -61,6 +62,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 5AEE5B362F5C9B7B00FA84D7 /* OracleNIO in Frameworks */, 5ACE00012F4F000000000004 /* CodeEditSourceEditor in Frameworks */, 5ACE00012F4F000000000005 /* CodeEditLanguages in Frameworks */, 5ACE00012F4F000000000006 /* CodeEditTextView in Frameworks */, @@ -131,6 +133,7 @@ 5ACE00012F4F000000000007 /* CodeEditTextView */, 5ACE00012F4F000000000009 /* Sparkle */, 5ACE00012F4F00000000000C /* MarkdownUI */, + 5ACE00012F4F00000000000F /* OracleNIO */, ); productName = TablePro; productReference = 5A1091C72EF17EDC0055EA7C /* TablePro.app */; @@ -191,6 +194,7 @@ 5ACE00012F4F000000000008 /* XCRemoteSwiftPackageReference "Sparkle" */, 5ACE00012F4F00000000000B /* XCRemoteSwiftPackageReference "swift-markdown-ui" */, 5A0000012F4F000000000100 /* XCLocalSwiftPackageReference "LocalPackages/CodeEditLanguages" */, + 5ACE00012F4F00000000000E /* XCRemoteSwiftPackageReference "oracle-nio" */, ); preferredProjectObjectVersion = 77; productRefGroup = 5A1091C82EF17EDC0055EA7C /* Products */; @@ -680,6 +684,14 @@ minimumVersion = 2.0.0; }; }; + 5ACE00012F4F00000000000E /* XCRemoteSwiftPackageReference "oracle-nio" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/lovetodream/oracle-nio"; + requirement = { + kind = exactVersion; + version = "1.0.0-rc.4"; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -705,6 +717,11 @@ package = 5ACE00012F4F00000000000B /* XCRemoteSwiftPackageReference "swift-markdown-ui" */; productName = MarkdownUI; }; + 5ACE00012F4F00000000000F /* OracleNIO */ = { + isa = XCSwiftPackageProductDependency; + package = 5ACE00012F4F00000000000E /* XCRemoteSwiftPackageReference "oracle-nio" */; + productName = OracleNIO; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 5A1091BF2EF17EDC0055EA7C /* Project object */; diff --git a/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b83979b3..43e68d32 100644 --- a/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "3741c0b86a58bb65f84d3edc9c6fcc7e86ced1f068f57e4fef2e0fb7e230b153", + "originHash" : "a7a6b62d3a1069b1ea8b6d44c1a52d154af36b1945f05d7b91799e978f549468", "pins" : [ { "identity" : "codeeditsymbols", @@ -28,6 +28,15 @@ "version" : "6.0.1" } }, + { + "identity" : "oracle-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/lovetodream/oracle-nio", + "state" : { + "revision" : "182c0f032326b5d437f80eb991570381cb48eb02", + "version" : "1.0.0-rc.4" + } + }, { "identity" : "rearrange", "kind" : "remoteSourceControl", @@ -46,6 +55,33 @@ "version" : "2.8.1" } }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "810496cf121e525d660cd0ea89a758740476b85f", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "9d349bcc328ac3c31ce40e746b5882742a0d1272", + "version" : "1.1.3" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" + } + }, { "identity" : "swift-cmark", "kind" : "remoteSourceControl", @@ -64,6 +100,33 @@ "version" : "1.4.0" } }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "6f70fa9eab24c1fd982af18c281c4525d05e3095", + "version" : "4.2.0" + } + }, + { + "identity" : "swift-distributed-tracing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-distributed-tracing.git", + "state" : { + "revision" : "e109d8b5308d0e05201d9a1dd1c475446a946a11", + "version" : "1.4.0" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "bbd81b6725ae874c69e9b8c8804d462356b55523", + "version" : "1.10.1" + } + }, { "identity" : "swift-markdown-ui", "kind" : "remoteSourceControl", @@ -73,6 +136,69 @@ "version" : "2.4.1" } }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "e932d3c4d8f77433c8f7093b5ebcbf91463948a0", + "version" : "2.95.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "173cc69a058623525a58ae6710e2f5727c663793", + "version" : "2.36.0" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "60c3e187154421171721c1a38e800b390680fb5d", + "version" : "1.26.0" + } + }, + { + "identity" : "swift-service-context", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-service-context.git", + "state" : { + "revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle.git", + "state" : { + "revision" : "89888196dd79c61c50bca9a103d8114f32e1e598", + "version" : "2.10.1" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "4799286537280063c85a32f09884cfbca301b1a1", + "version" : "602.0.0" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" + } + }, { "identity" : "swiftlintplugin", "kind" : "remoteSourceControl", diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index a12e341c..0c3c3914 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -54,7 +54,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { private static let databaseURLSchemes: Set = [ "postgresql", "postgres", "mysql", "mariadb", "sqlite", "mongodb", "mongodb+srv", "redis", "rediss", "redshift", - "mssql", "sqlserver" + "mssql", "sqlserver", "oracle" ] func applicationDockMenu(_ sender: NSApplication) -> NSMenu? { @@ -385,7 +385,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { sslConfig: sslConfig, color: color, tagId: tagId, - redisDatabase: parsed.redisDatabase + redisDatabase: parsed.redisDatabase, + oracleServiceName: parsed.oracleServiceName ) } @@ -515,6 +516,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { object: nil, userInfo: ["connectionId": connectionId, "schema": schema] ) + // Wait for schema switch to propagate through SwiftUI state before opening table try? await Task.sleep(for: .milliseconds(500)) } @@ -528,7 +530,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { WindowOpener.shared.openNativeTab(payload) if parsed.filterColumn != nil || parsed.filterCondition != nil { - try? await Task.sleep(for: .milliseconds(800)) + // Wait for table data to load before applying filter via notification + try? await Task.sleep(for: .milliseconds(500)) NotificationCenter.default.post( name: .applyURLFilter, object: nil, @@ -645,10 +648,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { private func scheduleWelcomeWindowSuppression() { Task { @MainActor [weak self] in - // Single check after a short delay for window creation + // Wait for SwiftUI to create the main window after file-open triggers connection try? await Task.sleep(for: .milliseconds(300)) self?.closeWelcomeWindowIfMainExists() - // One final check after windows settle + // Second check after windows fully settle (animations, state restoration) try? await Task.sleep(for: .milliseconds(700)) guard let self else { return } self.closeWelcomeWindowIfMainExists() @@ -678,6 +681,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { private func postSQLFilesWhenReady(urls: [URL]) { Task { @MainActor [weak self] in + // Brief delay to let the main window become key after connection completes try? await Task.sleep(for: .milliseconds(100)) if !NSApp.windows.contains(where: { self?.isMainWindow($0) == true && $0.isKeyWindow }) { Self.logger.warning("postSQLFilesWhenReady: no key main window, posting anyway") @@ -874,7 +878,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { } private func configureWelcomeWindow() { - // Wait for SwiftUI to create the welcome window, then configure it + // SwiftUI creates the welcome window asynchronously after app launch. + // Poll up to 5 times (250ms total) waiting for it to appear so we can + // configure AppKit-level style properties (hide miniaturize/zoom buttons, etc.). Task { @MainActor [weak self] in for _ in 0 ..< 5 { guard let self else { return } diff --git a/TablePro/Assets.xcassets/oracle-icon.imageset/Contents.json b/TablePro/Assets.xcassets/oracle-icon.imageset/Contents.json new file mode 100644 index 00000000..9a9bfb9e --- /dev/null +++ b/TablePro/Assets.xcassets/oracle-icon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "oracle.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/TablePro/Assets.xcassets/oracle-icon.imageset/oracle.svg b/TablePro/Assets.xcassets/oracle-icon.imageset/oracle.svg new file mode 100644 index 00000000..da8fb7f1 --- /dev/null +++ b/TablePro/Assets.xcassets/oracle-icon.imageset/oracle.svg @@ -0,0 +1 @@ + diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index 7dd51c19..9e01748c 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -25,7 +25,8 @@ struct ContentView: View { @State private var connectionToDelete: DatabaseConnection? @State private var showDeleteConfirmation = false @State private var hasLoaded = false - @State private var rightPanelState = RightPanelState() + @State private var rightPanelState: RightPanelState? + @State private var sessionState: SessionStateFactory.SessionState? @State private var inspectorContext = InspectorContext.empty @State private var windowTitle: String @Environment(\.openWindow) @@ -104,6 +105,15 @@ struct ContentView: View { currentSession = DatabaseManager.shared.activeSessions[connectionId] columnVisibility = currentSession != nil ? .all : .detailOnly if let session = currentSession { + if rightPanelState == nil { + rightPanelState = RightPanelState() + } + if sessionState == nil { + sessionState = SessionStateFactory.create( + connection: session.connection, + payload: payload + ) + } AppState.shared.isConnected = true AppState.shared.isReadOnly = session.connection.isReadOnly AppState.shared.isMongoDB = session.connection.type == .mongodb @@ -123,12 +133,32 @@ struct ContentView: View { } guard let newSession = sessions[sid] else { if currentSession?.id == sid { + rightPanelState?.teardown() + rightPanelState = nil + sessionState?.coordinator.teardown() + sessionState = nil currentSession = nil columnVisibility = .detailOnly AppState.shared.isConnected = false AppState.shared.isReadOnly = false AppState.shared.isMongoDB = false AppState.shared.isRedis = false + + // Close all native tab windows for this connection and + // force AppKit to deallocate them instead of pooling. + let tabbingId = "com.TablePro.main.\(sid.uuidString)" + DispatchQueue.main.async { + for window in NSApp.windows where window.tabbingIdentifier == tabbingId { + window.isReleasedWhenClosed = true + window.close() + } + malloc_zone_pressure_relief(nil, 0) + } + + // Defer a second malloc pass after SwiftUI processes state changes + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + malloc_zone_pressure_relief(nil, 0) + } } return } @@ -137,6 +167,15 @@ struct ContentView: View { return } currentSession = newSession + if rightPanelState == nil { + rightPanelState = RightPanelState() + } + if sessionState == nil { + sessionState = SessionStateFactory.create( + connection: newSession.connection, + payload: payload + ) + } AppState.shared.isConnected = true AppState.shared.isReadOnly = newSession.connection.isReadOnly AppState.shared.isMongoDB = newSession.connection.type == .mongodb @@ -178,7 +217,7 @@ struct ContentView: View { @ViewBuilder private var mainContent: some View { - if let currentSession = currentSession { + if let currentSession = currentSession, let rightPanelState, let sessionState { NavigationSplitView(columnVisibility: $columnVisibility) { // MARK: - Sidebar (Left) - Table Browser VStack(spacing: 0) { @@ -210,7 +249,12 @@ struct ContentView: View { pendingDeletes: sessionPendingDeletesBinding, tableOperationOptions: sessionTableOperationOptionsBinding, inspectorContext: $inspectorContext, - rightPanelState: rightPanelState + rightPanelState: rightPanelState, + tabManager: sessionState.tabManager, + changeManager: sessionState.changeManager, + filterStateManager: sessionState.filterStateManager, + toolbarState: sessionState.toolbarState, + coordinator: sessionState.coordinator ) .inspector(isPresented: Bindable(rightPanelState).isPresented) { UnifiedRightPanelView( @@ -222,7 +266,6 @@ struct ContentView: View { .frame(minWidth: 280, maxWidth: 500) .inspectorColumnWidth(min: 280, ideal: 320, max: 500) } - .id(currentSession.id) } .navigationTitle(windowTitle) .navigationSubtitle(currentSession.connection.name) diff --git a/TablePro/Core/Autocomplete/SQLCompletionProvider.swift b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift index 24d801a0..fcaa4340 100644 --- a/TablePro/Core/Autocomplete/SQLCompletionProvider.swift +++ b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift @@ -512,6 +512,16 @@ final class SQLCompletionProvider { "ROWVERSION", "HIERARCHYID", ] + case .oracle: + types += [ + "NUMBER", "BINARY_FLOAT", "BINARY_DOUBLE", + "VARCHAR2", "NVARCHAR2", "NCHAR", "NCLOB", + "CLOB", "LONG", "RAW", "LONG RAW", "BFILE", + "TIMESTAMP WITH TIME ZONE", "TIMESTAMP WITH LOCAL TIME ZONE", + "INTERVAL YEAR TO MONTH", "INTERVAL DAY TO SECOND", + "ROWID", "UROWID", "XMLTYPE", "SDO_GEOMETRY", + ] + case .sqlite: types += [ "BLOB", diff --git a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift index 30a6680d..103b71ac 100644 --- a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift +++ b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift @@ -100,8 +100,8 @@ struct SQLStatementGenerator { switch databaseType { case .postgresql, .redshift: return "$\(index + 1)" // PostgreSQL uses $1, $2, etc. - case .mysql, .mariadb, .sqlite, .mongodb, .redis, .mssql: - return "?" // MySQL, MariaDB, SQLite, MongoDB, and MSSQL use ? + case .mysql, .mariadb, .sqlite, .mongodb, .redis, .mssql, .oracle: + return "?" // MySQL, MariaDB, SQLite, MongoDB, MSSQL, and Oracle use ? } } @@ -275,6 +275,8 @@ struct SQLStatementGenerator { sql = "UPDATE \(databaseType.quoteIdentifier(tableName)) SET \(setClauses) WHERE \(whereClause) LIMIT 1" case .mssql: sql = "UPDATE TOP (1) \(databaseType.quoteIdentifier(tableName)) SET \(setClauses) WHERE \(whereClause)" + case .oracle: + sql = "UPDATE \(databaseType.quoteIdentifier(tableName)) SET \(setClauses) WHERE \(whereClause) AND ROWNUM = 1" case .postgresql, .redshift, .mongodb, .redis: sql = "UPDATE \(databaseType.quoteIdentifier(tableName)) SET \(setClauses) WHERE \(whereClause)" } @@ -349,6 +351,8 @@ struct SQLStatementGenerator { sql = "DELETE FROM \(databaseType.quoteIdentifier(tableName)) WHERE \(whereClause) LIMIT 1" case .mssql: sql = "DELETE TOP (1) FROM \(databaseType.quoteIdentifier(tableName)) WHERE \(whereClause)" + case .oracle: + sql = "DELETE FROM \(databaseType.quoteIdentifier(tableName)) WHERE \(whereClause) AND ROWNUM = 1" case .postgresql, .redshift, .mongodb, .redis: sql = "DELETE FROM \(databaseType.quoteIdentifier(tableName)) WHERE \(whereClause)" } diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index d830be20..2f85197e 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -275,6 +275,8 @@ extension DatabaseDriver { break // Redis does not support session-level query timeouts case .mssql: _ = try await execute(query: "SET LOCK_TIMEOUT \(ms)") + case .oracle: + break // Oracle timeout handled per-statement by OracleDriver } } catch { Logger(subsystem: "com.TablePro", category: "DatabaseDriver") @@ -318,6 +320,8 @@ enum DatabaseDriverFactory { return RedisDriver(connection: connection) case .mssql: return MSSQLDriver(connection: connection) + case .oracle: + return OracleDriver(connection: connection) } } } diff --git a/TablePro/Core/Database/DatabaseManager.swift b/TablePro/Core/Database/DatabaseManager.swift index 753fa1e5..a14eef29 100644 --- a/TablePro/Core/Database/DatabaseManager.swift +++ b/TablePro/Core/Database/DatabaseManager.swift @@ -135,6 +135,8 @@ final class DatabaseManager { activeSessions[connection.id]?.currentSchema = pgDriver.currentSchema } else if let rsDriver = driver as? RedshiftDriver { activeSessions[connection.id]?.currentSchema = rsDriver.currentSchema + } else if let oracleDriver = driver as? OracleDriver { + activeSessions[connection.id]?.currentSchema = oracleDriver.currentSchema } else if connection.type == .redis { // Redis defaults to db0 on connect; SELECT the configured database if non-default let initialDb = connection.redisDatabase ?? Int(connection.database) ?? 0 @@ -190,6 +192,8 @@ final class DatabaseManager { try? await pgMetaDriver.switchSchema(to: savedSchema) } else if let rsMetaDriver = metaDriver as? RedshiftDriver { try? await rsMetaDriver.switchSchema(to: savedSchema) + } else if let oracleMetaDriver = metaDriver as? OracleDriver { + try? await oracleMetaDriver.switchSchema(to: savedSchema) } } activeSessions[metaConnectionId]?.metadataDriver = metaDriver @@ -542,6 +546,8 @@ final class DatabaseManager { try? await pgDriver.switchSchema(to: savedSchema) } else if let rsDriver = driver as? RedshiftDriver { try? await rsDriver.switchSchema(to: savedSchema) + } else if let oracleDriver = driver as? OracleDriver { + try? await oracleDriver.switchSchema(to: savedSchema) } } @@ -610,12 +616,14 @@ final class DatabaseManager { try await driver.applyQueryTimeout(timeoutSeconds) } - // Restore schema for PostgreSQL/Redshift if session had a non-default schema + // Restore schema for PostgreSQL/Redshift/Oracle if session had a non-default schema if let savedSchema = activeSessions[sessionId]?.currentSchema { if let pgDriver = driver as? PostgreSQLDriver { try? await pgDriver.switchSchema(to: savedSchema) } else if let rsDriver = driver as? RedshiftDriver { try? await rsDriver.switchSchema(to: savedSchema) + } else if let oracleDriver = driver as? OracleDriver { + try? await oracleDriver.switchSchema(to: savedSchema) } } @@ -650,6 +658,8 @@ final class DatabaseManager { try? await pgMetaDriver.switchSchema(to: savedSchema) } else if let rsMetaDriver = metaDriver as? RedshiftDriver { try? await rsMetaDriver.switchSchema(to: savedSchema) + } else if let oracleMetaDriver = metaDriver as? OracleDriver { + try? await oracleMetaDriver.switchSchema(to: savedSchema) } } // Restore database on metadata driver too for MSSQL @@ -685,7 +695,7 @@ final class DatabaseManager { // MARK: - SSH Tunnel Recovery - /// Handle SSH tunnel death by attempting reconnection + /// Handle SSH tunnel death by attempting reconnection with exponential backoff private func handleSSHTunnelDied(connectionId: UUID) async { guard let session = activeSessions[connectionId] else { return } @@ -696,22 +706,29 @@ final class DatabaseManager { session.status = .connecting } - // Wait a bit before attempting reconnection (give VPN time to reconnect) - try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds + let maxRetries = 5 + for retryCount in 0.. Int in + state += 1 + return state + } + + do { + let connection = try await OracleNIO.OracleConnection.connect( + configuration: config, + id: connectionId, + logger: nioLogger + ) + + lock.lock() + nioConnection = connection + _isConnected = true + lock.unlock() + + osLogger.debug("Connected to Oracle \(self.host):\(self.port)/\(service)") + } catch let sqlError as OracleSQLError { + let detail = sqlError.serverInfo?.message ?? sqlError.description + osLogger.error("Oracle connection failed: \(detail)") + throw OracleError(message: "Failed to connect to \(host):\(port)/\(service): \(detail)") + } catch { + let detail = String(describing: error) + osLogger.error("Oracle connection failed: \(detail)") + throw OracleError(message: "Failed to connect to \(host):\(port)/\(service): \(detail)") + } + } + + func disconnect() { + lock.lock() + guard _isConnected else { + lock.unlock() + return + } + _isConnected = false + let connection = nioConnection + nioConnection = nil + lock.unlock() + + Task { + try? await connection?.close() + osLogger.debug("Disconnected from Oracle \(self.host):\(self.port)") + } + } + + // MARK: - Query Execution + + func executeQuery(_ query: String) async throws -> OracleQueryResult { + lock.lock() + guard let connection = nioConnection, _isConnected else { + lock.unlock() + throw OracleError.notConnected + } + lock.unlock() + + do { + let statement: OracleStatement = OracleStatement(stringLiteral: query) + let stream = try await connection.execute(statement, logger: nioLogger) + + // Read column metadata from stream (available even with 0 rows) + var columns: [String] = [] + for col in stream.columns { + columns.append(col.name) + } + osLogger.debug("Oracle columns: \(columns.count) — \(columns.joined(separator: ", "))") + + var columnTypeNames: [String] = [] + var allRows: [[String?]] = [] + var didReadTypes = false + + for try await row in stream { + var rowValues: [String?] = [] + for cell in row { + if !didReadTypes { + columnTypeNames.append(oracleTypeName(cell.dataType)) + } + if cell.bytes == nil { + rowValues.append(nil) + } else { + rowValues.append(decodeCell(cell)) + } + } + didReadTypes = true + allRows.append(rowValues) + } + + // If no rows were returned, fill type names with "unknown" + if !didReadTypes { + columnTypeNames = Array(repeating: "unknown", count: columns.count) + } + + return OracleQueryResult( + columns: columns, + columnTypeNames: columnTypeNames, + rows: allRows, + affectedRows: allRows.count + ) + } catch let sqlError as OracleSQLError { + let detail = sqlError.serverInfo?.message ?? sqlError.description + throw OracleError(message: detail) + } catch let error as OracleError { + throw error + } catch { + throw OracleError(message: "Query execution failed: \(String(describing: error))") + } + } + + // MARK: - Private Helpers + + /// Decode an OracleCell to String, trying multiple type strategies. + /// OracleNIO may fail to decode NUMBER as String directly. + private func decodeCell(_ cell: OracleCell) -> String? { + if let value = try? cell.decode(String.self) { return value } + if let value = try? cell.decode(Int.self) { return String(value) } + if let value = try? cell.decode(Double.self) { return String(value) } + if let value = try? cell.decode(Bool.self) { return String(value) } + // Last resort: read raw bytes as UTF-8 + if var buf = cell.bytes { + return buf.readString(length: buf.readableBytes) + } + return nil + } + + private func oracleTypeName(_ dataType: OracleDataType) -> String { + if dataType == .varchar { return "varchar2" } + if dataType == .number { return "number" } + if dataType == .binaryFloat { return "binary_float" } + if dataType == .binaryDouble { return "binary_double" } + if dataType == .date { return "date" } + if dataType == .raw { return "raw" } + if dataType == .longRAW { return "long raw" } + if dataType == .char { return "char" } + if dataType == .nChar { return "nchar" } + if dataType == .nVarchar { return "nvarchar2" } + if dataType == .nCLOB { return "nclob" } + if dataType == .clob { return "clob" } + if dataType == .blob { return "blob" } + if dataType == .bFile { return "bfile" } + if dataType == .timestamp { return "timestamp" } + if dataType == .timestampTZ { return "timestamp with time zone" } + if dataType == .timestampLTZ { return "timestamp with local time zone" } + if dataType == .intervalDS { return "interval day to second" } + if dataType == .intervalYM { return "interval year to month" } + if dataType == .rowID { return "rowid" } + if dataType == .boolean { return "boolean" } + if dataType == .long { return "long" } + if dataType == .json { return "json" } + if dataType == .vector { return "vector" } + if dataType == .binaryInteger { return "binary_integer" } + return "unknown" + } +} diff --git a/TablePro/Core/Database/OracleDriver.swift b/TablePro/Core/Database/OracleDriver.swift new file mode 100644 index 00000000..8588b3a7 --- /dev/null +++ b/TablePro/Core/Database/OracleDriver.swift @@ -0,0 +1,547 @@ +// +// OracleDriver.swift +// TablePro +// +// Oracle Database driver using OCI +// + +import Foundation +import OSLog + +private let logger = Logger(subsystem: "com.TablePro", category: "OracleDriver") + +final class OracleDriver: DatabaseDriver { + let connection: DatabaseConnection + private(set) var status: ConnectionStatus = .disconnected + + private var oracleConn: OracleConnectionWrapper? + + private(set) var currentSchema: String = "" + + var escapedSchema: String { + SQLEscaping.escapeStringLiteral(currentSchema, databaseType: .oracle) + } + + var serverVersion: String? { + _serverVersion + } + private var _serverVersion: String? + + init(connection: DatabaseConnection) { + self.connection = connection + } + + // MARK: - Connection + + func connect() async throws { + status = .connecting + let conn = OracleConnectionWrapper( + host: connection.host, + port: connection.port, + user: connection.username, + password: ConnectionStorage.shared.loadPassword(for: connection.id) ?? "", + database: connection.database, + serviceName: connection.oracleServiceName ?? "" + ) + do { + try await conn.connect() + self.oracleConn = conn + status = .connected + + // Get current schema (defaults to username) + if let result = try? await conn.executeQuery("SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') FROM DUAL"), + let schema = result.rows.first?.first ?? nil { + currentSchema = schema + } else { + currentSchema = connection.username.uppercased() + } + + if let result = try? await conn.executeQuery("SELECT BANNER FROM V$VERSION WHERE ROWNUM = 1"), + let versionStr = result.rows.first?.first ?? nil { + _serverVersion = String(versionStr.prefix(60)) + } + } catch { + status = .error(error.localizedDescription) + throw error + } + } + + func disconnect() { + oracleConn?.disconnect() + oracleConn = nil + status = .disconnected + } + + // MARK: - Query Execution + + func execute(query: String) async throws -> QueryResult { + guard let conn = oracleConn else { + throw DatabaseError.connectionFailed("Not connected to Oracle") + } + let startTime = Date() + + // Health monitor sends "SELECT 1" as a ping — Oracle requires FROM DUAL. + var effectiveQuery = query + if query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "select 1" { + effectiveQuery = "SELECT 1 FROM DUAL" + } + + var result = try await conn.executeQuery(effectiveQuery) + let executionTime = Date().timeIntervalSince(startTime) + + // OracleNIO may not populate column metadata for empty result sets. + // Fall back to ALL_TAB_COLUMNS to get column names for the table. + if result.columns.isEmpty && result.rows.isEmpty { + if let table = Self.extractTableNameFromSelect(query) { + let escapedTable = table.replacingOccurrences(of: "'", with: "''") + let colSQL = """ + SELECT COLUMN_NAME, DATA_TYPE FROM ALL_TAB_COLUMNS \ + WHERE OWNER = '\(escapedSchema)' AND TABLE_NAME = '\(escapedTable)' \ + ORDER BY COLUMN_ID + """ + if let colResult = try? await conn.executeQuery(colSQL) { + let colNames = colResult.rows.compactMap { $0.first ?? nil } + let colTypes = colResult.rows.map { ($0[safe: 1] ?? nil)?.lowercased() ?? "varchar2" } + if !colNames.isEmpty { + result = OracleQueryResult( + columns: colNames, + columnTypeNames: colTypes, + rows: [], + affectedRows: 0 + ) + } + } + } + } + + return mapToQueryResult(result, executionTime: executionTime) + } + + func executeParameterized(query: String, parameters: [Any?]) async throws -> QueryResult { + let statement = ParameterizedStatement(sql: query, parameters: parameters) + let built = SQLParameterInliner.inline(statement, databaseType: .oracle) + return try await execute(query: built) + } + + func fetchRowCount(query: String) async throws -> Int { + let countQuery = "SELECT COUNT(*) FROM (\(query))" + let result = try await execute(query: countQuery) + guard let row = result.rows.first, + let cell = row.first, + let str = cell, + let count = Int(str) else { + return 0 + } + return count + } + + func fetchRows(query: String, offset: Int, limit: Int) async throws -> QueryResult { + var base = query.trimmingCharacters(in: .whitespacesAndNewlines) + while base.hasSuffix(";") { + base = String(base.dropLast()).trimmingCharacters(in: .whitespacesAndNewlines) + } + // Strip any existing OFFSET/FETCH + base = stripOracleOffsetFetch(from: base) + let orderBy = hasTopLevelOrderBy(base) ? "" : " ORDER BY 1" + let paginated = "\(base)\(orderBy) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY" + return try await execute(query: paginated) + } + + private func hasTopLevelOrderBy(_ query: String) -> Bool { + let ns = query.uppercased() as NSString + let len = ns.length + guard len >= 8 else { return false } + var depth = 0 + var i = len - 1 + while i >= 7 { + let ch = ns.character(at: i) + if ch == 0x29 { depth += 1 } + else if ch == 0x28 { depth -= 1 } + else if depth == 0 && ch == 0x59 { + let start = i - 7 + if start >= 0 { + let candidate = ns.substring(with: NSRange(location: start, length: 8)) + if candidate == "ORDER BY" { return true } + } + } + i -= 1 + } + return false + } + + private func stripOracleOffsetFetch(from query: String) -> String { + let ns = query.uppercased() as NSString + let len = ns.length + guard len >= 6 else { return query } + var depth = 0 + var i = len - 1 + while i >= 5 { + let ch = ns.character(at: i) + if ch == 0x29 { depth += 1 } + else if ch == 0x28 { depth -= 1 } + else if depth == 0 && ch == 0x54 { + let start = i - 5 + if start >= 0 { + let candidate = ns.substring(with: NSRange(location: start, length: 6)) + if candidate == "OFFSET" { + return (query as NSString).substring(to: start) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + } + } + i -= 1 + } + return query + } + + // MARK: - Schema Operations + + func fetchTables() async throws -> [TableInfo] { + let sql = """ + SELECT table_name, 'BASE TABLE' AS table_type FROM all_tables WHERE owner = '\(escapedSchema)' + UNION ALL + SELECT view_name, 'VIEW' FROM all_views WHERE owner = '\(escapedSchema)' + ORDER BY 1 + """ + let result = try await execute(query: sql) + return result.rows.compactMap { row -> TableInfo? in + guard let name = row[safe: 0] ?? nil else { return nil } + let rawType = row[safe: 1] ?? nil + let tableType: TableInfo.TableType = (rawType == "VIEW") ? .view : .table + return TableInfo(name: name, type: tableType, rowCount: nil) + } + } + + func fetchColumns(table: String) async throws -> [ColumnInfo] { + let escapedTable = table.replacingOccurrences(of: "'", with: "''") + let sql = """ + SELECT + c.COLUMN_NAME, + c.DATA_TYPE, + c.DATA_LENGTH, + 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 ( + SELECT acc.COLUMN_NAME + FROM ALL_CONS_COLUMNS acc + JOIN ALL_CONSTRAINTS ac ON acc.CONSTRAINT_NAME = ac.CONSTRAINT_NAME + AND acc.OWNER = ac.OWNER + WHERE ac.CONSTRAINT_TYPE = 'P' + AND ac.OWNER = '\(escapedSchema)' + AND ac.TABLE_NAME = '\(escapedTable)' + ) cc ON c.COLUMN_NAME = cc.COLUMN_NAME + WHERE c.OWNER = '\(escapedSchema)' + AND c.TABLE_NAME = '\(escapedTable)' + ORDER BY c.COLUMN_ID + """ + let result = try await execute(query: sql) + return result.rows.compactMap { row -> ColumnInfo? in + guard let name = row[safe: 0] ?? nil else { return nil } + let dataType = (row[safe: 1] ?? nil)?.lowercased() ?? "varchar2" + let dataLength = row[safe: 2] ?? nil + 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 fixedTypes: Set = [ + "date", "clob", "nclob", "blob", "bfile", "long", "long raw", + "rowid", "urowid", "binary_float", "binary_double", "xmltype" + ] + var fullType = dataType + if fixedTypes.contains(dataType) { + // No suffix + } else if dataType == "number" { + if let p = precision, let pInt = Int(p) { + if let s = scale, let sInt = Int(s), sInt > 0 { + fullType = "number(\(pInt),\(sInt))" + } else { + fullType = "number(\(pInt))" + } + } + } else if let len = dataLength, let lenInt = Int(len), lenInt > 0 { + fullType = "\(dataType)(\(lenInt))" + } + + return ColumnInfo( + name: name, + dataType: fullType, + isNullable: isNullable, + isPrimaryKey: isPk, + defaultValue: defaultValue, + extra: nil, + charset: nil, + collation: nil, + comment: nil + ) + } + } + + func fetchIndexes(table: String) async throws -> [IndexInfo] { + let escapedTable = table.replacingOccurrences(of: "'", with: "''") + let sql = """ + SELECT i.INDEX_NAME, i.UNIQUENESS, ic.COLUMN_NAME, + CASE WHEN c.CONSTRAINT_TYPE = 'P' THEN 'Y' ELSE 'N' END AS IS_PK + FROM ALL_INDEXES i + JOIN ALL_IND_COLUMNS ic ON i.INDEX_NAME = ic.INDEX_NAME AND i.OWNER = ic.INDEX_OWNER + LEFT JOIN ALL_CONSTRAINTS c ON i.INDEX_NAME = c.INDEX_NAME AND i.OWNER = c.OWNER + AND c.CONSTRAINT_TYPE = 'P' + WHERE i.TABLE_NAME = '\(escapedTable)' + AND i.OWNER = '\(escapedSchema)' + ORDER BY i.INDEX_NAME, ic.COLUMN_POSITION + """ + let result = try await execute(query: sql) + var indexMap: [String: (unique: Bool, primary: Bool, columns: [String])] = [:] + for row in result.rows { + guard let idxName = row[safe: 0] ?? nil, + let colName = row[safe: 2] ?? nil else { continue } + let isUnique = (row[safe: 1] ?? nil) == "UNIQUE" + let isPrimary = (row[safe: 3] ?? nil) == "Y" + if indexMap[idxName] == nil { + indexMap[idxName] = (unique: isUnique, primary: isPrimary, columns: []) + } + indexMap[idxName]?.columns.append(colName) + } + return indexMap.map { name, info in + IndexInfo( + name: name, + columns: info.columns, + isUnique: info.unique, + isPrimary: info.primary, + type: "BTREE" + ) + }.sorted { $0.name < $1.name } + } + + func fetchForeignKeys(table: String) async throws -> [ForeignKeyInfo] { + let escapedTable = table.replacingOccurrences(of: "'", with: "''") + let sql = """ + SELECT + ac.CONSTRAINT_NAME, + acc.COLUMN_NAME, + rc.TABLE_NAME AS REF_TABLE, + rcc.COLUMN_NAME AS REF_COLUMN, + ac.DELETE_RULE + FROM ALL_CONSTRAINTS ac + JOIN ALL_CONS_COLUMNS acc ON ac.CONSTRAINT_NAME = acc.CONSTRAINT_NAME + AND ac.OWNER = acc.OWNER + JOIN ALL_CONSTRAINTS rc ON ac.R_CONSTRAINT_NAME = rc.CONSTRAINT_NAME + AND ac.R_OWNER = rc.OWNER + JOIN ALL_CONS_COLUMNS rcc ON rc.CONSTRAINT_NAME = rcc.CONSTRAINT_NAME + AND rc.OWNER = rcc.OWNER AND acc.POSITION = rcc.POSITION + WHERE ac.CONSTRAINT_TYPE = 'R' + AND ac.TABLE_NAME = '\(escapedTable)' + AND ac.OWNER = '\(escapedSchema)' + ORDER BY ac.CONSTRAINT_NAME, acc.POSITION + """ + let result = try await execute(query: sql) + return result.rows.compactMap { row -> ForeignKeyInfo? in + guard let constraintName = row[safe: 0] ?? nil, + let columnName = row[safe: 1] ?? nil, + let refTable = row[safe: 2] ?? nil, + let refColumn = row[safe: 3] ?? nil else { return nil } + let deleteRule = (row[safe: 4] ?? nil) ?? "NO ACTION" + return ForeignKeyInfo( + name: constraintName, + column: columnName, + referencedTable: refTable, + referencedColumn: refColumn, + onDelete: deleteRule, + onUpdate: "NO ACTION" + ) + } + } + + func fetchApproximateRowCount(table: String) async throws -> Int? { + let escapedTable = table.replacingOccurrences(of: "'", with: "''") + let sql = """ + SELECT NUM_ROWS FROM ALL_TABLES + WHERE TABLE_NAME = '\(escapedTable)' AND OWNER = '\(escapedSchema)' + """ + let result = try await execute(query: sql) + if let row = result.rows.first, let cell = row.first, let str = cell { + return Int(str) + } + return nil + } + + func fetchTableDDL(table: String) async throws -> String { + let escapedTable = table.replacingOccurrences(of: "'", with: "''") + let sql = "SELECT DBMS_METADATA.GET_DDL('TABLE', '\(escapedTable)', '\(escapedSchema)') FROM DUAL" + do { + let result = try await execute(query: sql) + if let row = result.rows.first, let ddl = row.first ?? nil { + return ddl + } + } catch { + logger.debug("DBMS_METADATA failed, building DDL manually: \(error.localizedDescription)") + } + + // Fallback: build DDL from columns + let cols = try await fetchColumns(table: table) + var ddl = "CREATE TABLE \"\(escapedSchema)\".\"\(escapedTable)\" (\n" + let colDefs = cols.map { col -> String in + var def = " \"\(col.name)\" \(col.dataType.uppercased())" + if !col.isNullable { def += " NOT NULL" } + if let d = col.defaultValue, !d.isEmpty { def += " DEFAULT \(d)" } + return def + } + ddl += colDefs.joined(separator: ",\n") + ddl += "\n);" + return ddl + } + + func fetchViewDefinition(view: String) async throws -> String { + let escapedView = view.replacingOccurrences(of: "'", with: "''") + let sql = "SELECT TEXT FROM ALL_VIEWS WHERE VIEW_NAME = '\(escapedView)' AND OWNER = '\(escapedSchema)'" + let result = try await execute(query: sql) + return result.rows.first?.first?.flatMap { $0 } ?? "" + } + + func fetchTableMetadata(tableName: String) async throws -> TableMetadata { + let escapedTable = tableName.replacingOccurrences(of: "'", with: "''") + let sql = """ + SELECT + t.NUM_ROWS, + s.BYTES, + tc.COMMENTS + FROM ALL_TABLES t + LEFT JOIN ALL_SEGMENTS s ON t.TABLE_NAME = s.SEGMENT_NAME AND t.OWNER = s.OWNER + LEFT JOIN ALL_TAB_COMMENTS tc ON t.TABLE_NAME = tc.TABLE_NAME AND t.OWNER = tc.OWNER + WHERE t.TABLE_NAME = '\(escapedTable)' AND t.OWNER = '\(escapedSchema)' + """ + let result = try await execute(query: sql) + if let row = result.rows.first { + let rowCount = (row[safe: 0] ?? nil).flatMap { Int64($0) } + let sizeBytes = (row[safe: 1] ?? nil).flatMap { Int64($0) } ?? 0 + let comment = row[safe: 2] ?? nil + return TableMetadata( + tableName: tableName, + dataSize: sizeBytes, + indexSize: nil, + totalSize: sizeBytes, + avgRowLength: nil, + rowCount: rowCount, + comment: comment, + engine: nil, + collation: nil, + createTime: nil, + updateTime: nil + ) + } + return TableMetadata( + tableName: tableName, + dataSize: nil, indexSize: nil, totalSize: nil, + avgRowLength: nil, rowCount: nil, comment: nil, + engine: nil, collation: nil, createTime: nil, updateTime: nil + ) + } + + func fetchDatabases() async throws -> [String] { + // Oracle uses schemas instead of databases. List accessible schemas. + let sql = "SELECT USERNAME FROM ALL_USERS ORDER BY USERNAME" + let result = try await execute(query: sql) + return result.rows.compactMap { $0.first ?? nil } + } + + func fetchSchemas() async throws -> [String] { + let sql = "SELECT USERNAME FROM ALL_USERS ORDER BY USERNAME" + let result = try await execute(query: sql) + return result.rows.compactMap { $0.first ?? nil } + } + + func fetchDatabaseMetadata(_ database: String) async throws -> DatabaseMetadata { + let escapedDb = database.replacingOccurrences(of: "'", with: "''") + let sql = """ + SELECT + (SELECT COUNT(*) FROM ALL_TABLES WHERE OWNER = '\(escapedDb)') AS table_count, + (SELECT NVL(SUM(BYTES), 0) FROM ALL_SEGMENTS WHERE OWNER = '\(escapedDb)') AS size_bytes + FROM DUAL + """ + do { + let result = try await execute(query: sql) + if let row = result.rows.first { + let tableCount = (row[safe: 0] ?? nil).flatMap { Int($0) } ?? 0 + let sizeBytes = (row[safe: 1] ?? nil).flatMap { Int64($0) } ?? 0 + return DatabaseMetadata( + id: database, + name: database, + tableCount: tableCount, + sizeBytes: sizeBytes, + lastAccessed: nil, + isSystemDatabase: false, + icon: "cylinder.fill" + ) + } + } catch { + // DBA_SEGMENTS may not be accessible — fall back + } + return DatabaseMetadata.minimal(name: database) + } + + func createDatabase(name: String, charset: String, collation: String?) async throws { + throw DatabaseError.unsupportedOperation + } + + func cancelQuery() throws { + // OCI cancel not safe from different thread without OCIBreak — no-op for now + } + + // MARK: - Schema Switching + + func switchSchema(to schema: String) async throws { + let escaped = schema.replacingOccurrences(of: "\"", with: "\"\"") + _ = try await execute(query: "ALTER SESSION SET CURRENT_SCHEMA = \"\(escaped)\"") + currentSchema = schema + } + + // MARK: - Private Helpers + + private static let fromTableRegex = try? NSRegularExpression( + pattern: #"FROM\s+(?:"([^"]+)"|(\w+))"#, + options: .caseInsensitive + ) + + private static func extractTableNameFromSelect(_ sql: String) -> String? { + let trimmed = sql.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.range(of: "^SELECT\\b", options: [.regularExpression, .caseInsensitive]) != nil else { + return nil + } + let ns = trimmed as NSString + guard let match = fromTableRegex?.firstMatch( + in: trimmed, + range: NSRange(location: 0, length: ns.length) + ), match.numberOfRanges >= 3 else { + return nil + } + // Group 1 = double-quoted table, Group 2 = unquoted identifier + let quotedRange = match.range(at: 1) + if quotedRange.location != NSNotFound { + return ns.substring(with: quotedRange) + } + let unquotedRange = match.range(at: 2) + if unquotedRange.location != NSNotFound { + return ns.substring(with: unquotedRange) + } + return nil + } + + private func mapToQueryResult(_ oracleResult: OracleQueryResult, executionTime: TimeInterval) -> QueryResult { + let columnTypes = oracleResult.columnTypeNames.map { rawType in + ColumnType(fromOracleType: rawType) + } + return QueryResult( + columns: oracleResult.columns, + columnTypes: columnTypes, + rows: oracleResult.rows, + rowsAffected: oracleResult.affectedRows, + executionTime: executionTime, + error: nil + ) + } +} diff --git a/TablePro/Core/Database/SQLEscaping.swift b/TablePro/Core/Database/SQLEscaping.swift index a489cf6a..5574efcc 100644 --- a/TablePro/Core/Database/SQLEscaping.swift +++ b/TablePro/Core/Database/SQLEscaping.swift @@ -48,7 +48,7 @@ enum SQLEscaping { result = result.replacingOccurrences(of: "\u{1A}", with: "\\Z") // MySQL EOF marker (Ctrl+Z) return result - case .postgresql, .redshift, .sqlite, .mongodb, .redis, .mssql: + case .postgresql, .redshift, .sqlite, .mongodb, .redis, .mssql, .oracle: // Standard SQL: only single quotes need doubling // Newlines, tabs, backslashes are valid as-is in string literals var result = str diff --git a/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift b/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift index 8de9665b..e50462e8 100644 --- a/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift +++ b/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift @@ -128,7 +128,7 @@ struct SchemaStatementGenerator { let tableQuoted = databaseType.quoteIdentifier(tableName) let columnDef = try buildEditableColumnDefinition(column) - let keyword = databaseType == .mssql ? "ADD" : "ADD COLUMN" + let keyword = (databaseType == .mssql || databaseType == .oracle) ? "ADD" : "ADD COLUMN" let sql = "ALTER TABLE \(tableQuoted) \(keyword) \(columnDef)" return SchemaStatement( sql: sql, @@ -217,6 +217,35 @@ struct SchemaStatementGenerator { isDestructive: old.dataType != new.dataType ) + case .oracle: + var statements: [String] = [] + let newQuoted = databaseType.quoteIdentifier(new.name) + + if old.name != new.name { + let oldQuoted = databaseType.quoteIdentifier(old.name) + statements.append("ALTER TABLE \(tableQuoted) RENAME COLUMN \(oldQuoted) TO \(newQuoted)") + } + + if old.dataType != new.dataType || old.isNullable != new.isNullable { + let nullClause = new.isNullable ? "NULL" : "NOT NULL" + statements.append("ALTER TABLE \(tableQuoted) MODIFY (\(newQuoted) \(new.dataType) \(nullClause))") + } + + if old.defaultValue != new.defaultValue { + if let defaultVal = new.defaultValue, !defaultVal.isEmpty { + statements.append("ALTER TABLE \(tableQuoted) MODIFY (\(newQuoted) DEFAULT \(defaultVal))") + } else { + statements.append("ALTER TABLE \(tableQuoted) MODIFY (\(newQuoted) DEFAULT NULL)") + } + } + + let sql = statements.map { $0.hasSuffix(";") ? $0 : $0 + ";" }.joined(separator: "\n") + return SchemaStatement( + sql: sql, + description: "Modify column '\(old.name)' to '\(new.name)'", + isDestructive: old.dataType != new.dataType + ) + case .sqlite, .mongodb, .redis: // SQLite doesn't support ALTER COLUMN - requires table recreation // MongoDB/Redis don't use SQL ALTER TABLE @@ -274,6 +303,8 @@ struct SchemaStatementGenerator { break // MongoDB/Redis auto-generate IDs case .mssql: parts[1] = "INT IDENTITY(1,1)" + case .oracle: + parts.append("GENERATED ALWAYS AS IDENTITY") } } @@ -292,8 +323,8 @@ struct SchemaStatementGenerator { case .postgresql, .redshift: // PostgreSQL comments are set via separate COMMENT statement break - case .sqlite, .mongodb, .redis, .mssql: - // SQLite/MongoDB/Redis/MSSQL don't support inline column comments + case .sqlite, .mongodb, .redis, .mssql, .oracle: + // SQLite/MongoDB/Redis/MSSQL/Oracle don't support inline column comments break } } @@ -320,7 +351,7 @@ struct SchemaStatementGenerator { let indexTypeClause = index.type == .btree ? "" : "USING \(index.type.rawValue)" sql = "CREATE \(uniqueKeyword)INDEX \(indexQuoted) ON \(tableQuoted) \(indexTypeClause) (\(columnsQuoted))" - case .sqlite, .mongodb, .redis, .mssql: + case .sqlite, .mongodb, .redis, .mssql, .oracle: sql = "CREATE \(uniqueKeyword)INDEX \(indexQuoted) ON \(tableQuoted) (\(columnsQuoted))" } @@ -353,7 +384,7 @@ struct SchemaStatementGenerator { let tableQuoted = databaseType.quoteIdentifier(tableName) sql = "DROP INDEX \(indexQuoted) ON \(tableQuoted)" - case .postgresql, .redshift, .sqlite, .mongodb, .redis: + case .postgresql, .redshift, .sqlite, .mongodb, .redis, .oracle: sql = "DROP INDEX \(indexQuoted)" case .mssql: let tableQuoted = databaseType.quoteIdentifier(tableName) @@ -414,7 +445,7 @@ struct SchemaStatementGenerator { case .mysql, .mariadb: sql = "ALTER TABLE \(tableQuoted) DROP FOREIGN KEY \(fkQuoted)" - case .postgresql, .redshift, .mssql: + case .postgresql, .redshift, .mssql, .oracle: sql = "ALTER TABLE \(tableQuoted) DROP CONSTRAINT \(fkQuoted)" case .sqlite, .mongodb, .redis: throw DatabaseError.unsupportedOperation @@ -455,6 +486,13 @@ struct SchemaStatementGenerator { ALTER TABLE \(tableQuoted) ADD PRIMARY KEY (\(newColumnsQuoted)); """ + case .oracle: + let pkName = primaryKeyConstraintName ?? "PK_\(tableName)" + sql = """ + ALTER TABLE \(tableQuoted) DROP CONSTRAINT \(databaseType.quoteIdentifier(pkName)); + ALTER TABLE \(tableQuoted) ADD PRIMARY KEY (\(newColumnsQuoted)); + """ + case .sqlite, .mongodb, .redis: // SQLite doesn't support modifying primary keys - requires table recreation // MongoDB/Redis don't use SQL ALTER TABLE diff --git a/TablePro/Core/Services/ColumnType.swift b/TablePro/Core/Services/ColumnType.swift index 3f1c373d..3e1417fb 100644 --- a/TablePro/Core/Services/ColumnType.swift +++ b/TablePro/Core/Services/ColumnType.swift @@ -243,6 +243,47 @@ enum ColumnType: Equatable { } } + // MARK: - Oracle Type Mapping + + /// Initialize from Oracle data type name string + /// Reference: https://docs.oracle.com/en/database/oracle/oracle-database/23/sqlrf/Data-Types.html + init(fromOracleType declaredType: String?) { + guard let type = declaredType?.uppercased() else { + self = .text(rawType: declaredType) + return + } + + if type.contains("TIMESTAMP") { + self = .timestamp(rawType: declaredType) + } else if type == "DATE" { + self = .date(rawType: declaredType) + } else if type == "NUMBER" || type.hasPrefix("NUMBER(") || type == "INTEGER" + || type == "FLOAT" || type.hasPrefix("FLOAT(") + || type == "BINARY_FLOAT" || type == "BINARY_DOUBLE" + || type == "SMALLINT" || type == "BOOLEAN" { + self = .decimal(rawType: declaredType) + } else if type == "VARCHAR2" || type.hasPrefix("VARCHAR2(") + || type == "NVARCHAR2" || type.hasPrefix("NVARCHAR2(") + || type == "CHAR" || type.hasPrefix("CHAR(") + || type == "NCHAR" || type.hasPrefix("NCHAR(") + || type == "LONG" { + self = .text(rawType: declaredType) + } else if type == "CLOB" || type == "NCLOB" { + self = .text(rawType: declaredType) + } else if type == "BLOB" || type == "RAW" || type.hasPrefix("RAW(") + || type == "LONG RAW" || type == "BFILE" { + self = .blob(rawType: declaredType) + } else if type == "XMLTYPE" || type == "JSON" { + self = .json(rawType: declaredType) + } else if type == "ROWID" || type == "UROWID" || type.hasPrefix("UROWID(") { + self = .text(rawType: declaredType) + } else if type.hasPrefix("INTERVAL") { + self = .text(rawType: declaredType) + } else { + self = .text(rawType: declaredType) + } + } + // MARK: - Display Properties /// Human-readable name for this column type diff --git a/TablePro/Core/Services/ExportService+SQL.swift b/TablePro/Core/Services/ExportService+SQL.swift index e53e6435..f6f2232b 100644 --- a/TablePro/Core/Services/ExportService+SQL.swift +++ b/TablePro/Core/Services/ExportService+SQL.swift @@ -124,7 +124,15 @@ extension ExportService { try checkCancellation() try Task.checkCancellation() - let query = "SELECT * FROM \(tableRef) LIMIT \(batchSize) OFFSET \(offset)" + let query: String + switch databaseType { + case .oracle: + query = "SELECT * FROM \(tableRef) ORDER BY 1 OFFSET \(offset) ROWS FETCH NEXT \(batchSize) ROWS ONLY" + case .mssql: + query = "SELECT * FROM \(tableRef) ORDER BY (SELECT NULL) OFFSET \(offset) ROWS FETCH NEXT \(batchSize) ROWS ONLY" + default: + query = "SELECT * FROM \(tableRef) LIMIT \(batchSize) OFFSET \(offset)" + } let result = try await driver.execute(query: query) if result.rows.isEmpty { diff --git a/TablePro/Core/Services/ImportService.swift b/TablePro/Core/Services/ImportService.swift index a7f74092..1bb13d83 100644 --- a/TablePro/Core/Services/ImportService.swift +++ b/TablePro/Core/Services/ImportService.swift @@ -36,6 +36,7 @@ final class ImportService { // MARK: - Cancellation + // Lock is required despite @MainActor because _isCancelled is read from background Tasks private let isCancelledLock = NSLock() private var _isCancelled: Bool = false @@ -288,7 +289,7 @@ final class ImportService { switch dbType { case .mysql, .mariadb: return ["SET FOREIGN_KEY_CHECKS=0"] - case .postgresql, .redshift, .mssql: + case .postgresql, .redshift, .mssql, .oracle: // These databases don't support globally disabling non-deferrable FKs. return [] case .sqlite: @@ -302,7 +303,7 @@ final class ImportService { switch dbType { case .mysql, .mariadb: return ["SET FOREIGN_KEY_CHECKS=1"] - case .postgresql, .redshift, .mssql: + case .postgresql, .redshift, .mssql, .oracle: return [] case .sqlite: return ["PRAGMA foreign_keys = ON"] @@ -311,6 +312,8 @@ final class ImportService { } } + + private func commitStatement(for dbType: DatabaseType) -> String { switch dbType { case .mongodb, .redis: diff --git a/TablePro/Core/Services/SQLDialectProvider.swift b/TablePro/Core/Services/SQLDialectProvider.swift index 25aede5e..d6af1348 100644 --- a/TablePro/Core/Services/SQLDialectProvider.swift +++ b/TablePro/Core/Services/SQLDialectProvider.swift @@ -278,6 +278,68 @@ struct MSSQLDialect: SQLDialectProvider { ] } +// MARK: - Oracle Dialect + +struct OracleDialect: SQLDialectProvider { + let identifierQuote = "\"" + + let keywords: Set = [ + "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", "FULL", + "ON", "USING", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "AS", + "ORDER", "BY", "GROUP", "HAVING", "FETCH", "FIRST", "ROWS", "ONLY", "OFFSET", + "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", "MERGE", + + "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA", + "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", + "ADD", "MODIFY", "COLUMN", "RENAME", + + "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", "ANY", "SOME", + "SEQUENCE", "SYNONYM", "GRANT", "REVOKE", "TRIGGER", "PROCEDURE", + + "CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE", "NULLIF", "DECODE", + + "UNION", "INTERSECT", "MINUS", + + "DECLARE", "BEGIN", "COMMIT", "ROLLBACK", "SAVEPOINT", + "EXECUTE", "IMMEDIATE", + "OVER", "PARTITION", "ROW_NUMBER", "RANK", "DENSE_RANK", + "RETURNING", "CONNECT", "LEVEL", "START", "WITH", "PRIOR", + "ROWNUM", "ROWID", "DUAL", "SYSDATE", "SYSTIMESTAMP" + ] + + let functions: Set = [ + "COUNT", "SUM", "AVG", "MAX", "MIN", "LISTAGG", + + "CONCAT", "SUBSTR", "INSTR", "LENGTH", "LOWER", "UPPER", + "TRIM", "LTRIM", "RTRIM", "REPLACE", "LPAD", "RPAD", + "INITCAP", "TRANSLATE", + + "SYSDATE", "SYSTIMESTAMP", "CURRENT_DATE", "CURRENT_TIMESTAMP", + "ADD_MONTHS", "MONTHS_BETWEEN", "LAST_DAY", "NEXT_DAY", + "EXTRACT", "TO_DATE", "TO_CHAR", "TO_NUMBER", "TO_TIMESTAMP", + "TRUNC", "ROUND", + + "CEIL", "FLOOR", "ABS", "POWER", "SQRT", "MOD", "SIGN", + + "NVL", "NVL2", "DECODE", "COALESCE", "NULLIF", + "GREATEST", "LEAST", "CAST", + "SYS_GUID", "DBMS_RANDOM.VALUE", "USER", "SYS_CONTEXT" + ] + + let dataTypes: Set = [ + "NUMBER", "INTEGER", "SMALLINT", "FLOAT", "BINARY_FLOAT", "BINARY_DOUBLE", + + "CHAR", "VARCHAR2", "NCHAR", "NVARCHAR2", "CLOB", "NCLOB", "LONG", + + "BLOB", "RAW", "LONG RAW", "BFILE", + + "DATE", "TIMESTAMP", "TIMESTAMP WITH TIME ZONE", "TIMESTAMP WITH LOCAL TIME ZONE", + "INTERVAL YEAR TO MONTH", "INTERVAL DAY TO SECOND", + + "BOOLEAN", "ROWID", "UROWID", "XMLTYPE", "SDO_GEOMETRY" + ] +} + // MARK: - Dialect Factory struct SQLDialectFactory { @@ -296,6 +358,8 @@ struct SQLDialectFactory { return SQLiteDialect() // Placeholder until Redis dialect is implemented case .mssql: return MSSQLDialect() + case .oracle: + return OracleDialect() } } } diff --git a/TablePro/Core/Services/SessionStateFactory.swift b/TablePro/Core/Services/SessionStateFactory.swift new file mode 100644 index 00000000..a1db9cbf --- /dev/null +++ b/TablePro/Core/Services/SessionStateFactory.swift @@ -0,0 +1,92 @@ +// +// SessionStateFactory.swift +// TablePro +// +// Factory for creating session state objects used by MainContentView. +// Extracted from MainContentView.init to enable testability. +// + +import Foundation + +@MainActor +enum SessionStateFactory { + struct SessionState { + let tabManager: QueryTabManager + let changeManager: DataChangeManager + let filterStateManager: FilterStateManager + let toolbarState: ConnectionToolbarState + let coordinator: MainContentCoordinator + } + + static func create( + connection: DatabaseConnection, + payload: EditorTabPayload? + ) -> SessionState { + let tabMgr = QueryTabManager() + let changeMgr = DataChangeManager() + let filterMgr = FilterStateManager() + let toolbarSt = ConnectionToolbarState(connection: connection) + + // Eagerly populate version + state from existing session to avoid flash + if let session = DatabaseManager.shared.session(for: connection.id) { + toolbarSt.updateConnectionState(from: session.status) + if let driver = session.driver { + toolbarSt.databaseVersion = driver.serverVersion + } + } else if let driver = DatabaseManager.shared.driver(for: connection.id) { + toolbarSt.connectionState = .connected + toolbarSt.databaseVersion = driver.serverVersion + } + toolbarSt.hasCompletedSetup = true + + // Redis: set initial database name eagerly to avoid toolbar flash + if connection.type == .redis { + let dbIndex = connection.redisDatabase ?? Int(connection.database) ?? 0 + toolbarSt.databaseName = String(dbIndex) + } + + // Initialize single tab based on payload + if let payload, !payload.isConnectionOnly { + switch payload.tabType { + case .table: + if let tableName = payload.tableName { + tabMgr.addTableTab( + tableName: tableName, + databaseType: connection.type, + databaseName: payload.databaseName ?? connection.database + ) + if let index = tabMgr.selectedTabIndex { + tabMgr.tabs[index].isView = payload.isView + tabMgr.tabs[index].isEditable = !payload.isView + if payload.showStructure { + tabMgr.tabs[index].showStructure = true + } + } + } else { + tabMgr.addTab(databaseName: payload.databaseName ?? connection.database) + } + case .query: + tabMgr.addTab( + initialQuery: payload.initialQuery, + databaseName: payload.databaseName ?? connection.database + ) + } + } + + let coord = MainContentCoordinator( + connection: connection, + tabManager: tabMgr, + changeManager: changeMgr, + filterStateManager: filterMgr, + toolbarState: toolbarSt + ) + + return SessionState( + tabManager: tabMgr, + changeManager: changeMgr, + filterStateManager: filterMgr, + toolbarState: toolbarSt, + coordinator: coord + ) + } +} diff --git a/TablePro/Core/Services/TableQueryBuilder.swift b/TablePro/Core/Services/TableQueryBuilder.swift index 5fe4e26f..dafbc108 100644 --- a/TablePro/Core/Services/TableQueryBuilder.swift +++ b/TablePro/Core/Services/TableQueryBuilder.swift @@ -59,6 +59,13 @@ struct TableQueryBuilder { ) } + if databaseType == .oracle { + return buildOracleBaseQuery( + tableName: tableName, sortState: sortState, + columns: columns, limit: limit, offset: offset + ) + } + let quotedTable = databaseType.quoteIdentifier(tableName) var query = "SELECT * FROM \(quotedTable)" @@ -125,6 +132,18 @@ struct TableQueryBuilder { ) } + if databaseType == .oracle { + return buildOracleFilteredQuery( + tableName: tableName, + filters: filters, + logicMode: logicMode, + sortState: sortState, + columns: columns, + limit: limit, + offset: offset + ) + } + let quotedTable = databaseType.quoteIdentifier(tableName) var query = "SELECT * FROM \(quotedTable)" @@ -177,6 +196,20 @@ struct TableQueryBuilder { ) } + if databaseType == .mssql { + return buildMSSQLQuickSearchQuery( + tableName: tableName, searchText: searchText, columns: columns, + sortState: sortState, limit: limit, offset: offset + ) + } + + if databaseType == .oracle { + return buildOracleQuickSearchQuery( + tableName: tableName, searchText: searchText, columns: columns, + sortState: sortState, limit: limit, offset: offset + ) + } + let quotedTable = databaseType.quoteIdentifier(tableName) var query = "SELECT * FROM \(quotedTable)" @@ -297,6 +330,22 @@ struct TableQueryBuilder { } } + if databaseType == .mssql { + return buildMSSQLCombinedQuery( + tableName: tableName, filters: filters, logicMode: logicMode, + searchText: searchText, searchColumns: searchColumns, + sortState: sortState, columns: columns, limit: limit, offset: offset + ) + } + + if databaseType == .oracle { + return buildOracleCombinedQuery( + tableName: tableName, filters: filters, logicMode: logicMode, + searchText: searchText, searchColumns: searchColumns, + sortState: sortState, columns: columns, limit: limit, offset: offset + ) + } + let quotedTable = databaseType.quoteIdentifier(tableName) var query = "SELECT * FROM \(quotedTable)" @@ -362,11 +411,16 @@ struct TableQueryBuilder { let quotedColumn = databaseType.quoteIdentifier(columnName) let orderByClause = "ORDER BY \(quotedColumn) \(direction)" - // Insert ORDER BY before LIMIT if exists + // Insert ORDER BY before pagination clause if let limitRange = query.range(of: "LIMIT", options: .caseInsensitive) { let beforeLimit = query[.. String { + let quotedTable = databaseType.quoteIdentifier(tableName) + var query = "SELECT * FROM \(quotedTable)" + let escapedSearch = escapeForLike(searchText) + let conditions = columns.map { column -> String in + let quotedColumn = databaseType.quoteIdentifier(column) + return buildLikeCondition(column: quotedColumn, searchText: escapedSearch) + } + if !conditions.isEmpty { + query += " WHERE (" + conditions.joined(separator: " OR ") + ")" + } + let orderBy = buildOrderByClause(sortState: sortState, columns: columns) + ?? "ORDER BY (SELECT NULL)" + query += " \(orderBy) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY" + return query + } + + private func buildMSSQLCombinedQuery( + tableName: String, + filters: [TableFilter], + logicMode: FilterLogicMode, + searchText: String, + searchColumns: [String], + sortState: SortState?, + columns: [String], + limit: Int, + offset: Int + ) -> String { + let quotedTable = databaseType.quoteIdentifier(tableName) + var query = "SELECT * FROM \(quotedTable)" + let generator = FilterSQLGenerator(databaseType: databaseType) + let filterConditions = generator.generateConditions(from: filters, logicMode: logicMode) + let escapedSearch = escapeForLike(searchText) + let searchConditions = searchColumns.map { column -> String in + let quotedColumn = databaseType.quoteIdentifier(column) + return buildLikeCondition(column: quotedColumn, searchText: escapedSearch) + } + let searchClause = searchConditions.isEmpty ? "" : "(" + searchConditions.joined(separator: " OR ") + ")" + var whereParts: [String] = [] + if !filterConditions.isEmpty { + whereParts.append("(\(filterConditions))") + } + if !searchClause.isEmpty { + whereParts.append(searchClause) + } + if !whereParts.isEmpty { + query += " WHERE " + whereParts.joined(separator: " AND ") + } + let orderBy = buildOrderByClause(sortState: sortState, columns: columns) + ?? "ORDER BY (SELECT NULL)" + query += " \(orderBy) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY" + return query + } + + // MARK: - Oracle Query Helpers + + private func buildOracleBaseQuery( + tableName: String, + sortState: SortState?, + columns: [String], + limit: Int, + offset: Int + ) -> String { + let quotedTable = databaseType.quoteIdentifier(tableName) + var query = "SELECT * FROM \(quotedTable)" + let orderBy = buildOrderByClause(sortState: sortState, columns: columns) + ?? "ORDER BY 1" + query += " \(orderBy) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY" + return query + } + + private func buildOracleFilteredQuery( + tableName: String, + filters: [TableFilter], + logicMode: FilterLogicMode, + sortState: SortState?, + columns: [String], + limit: Int, + offset: Int + ) -> String { + let quotedTable = databaseType.quoteIdentifier(tableName) + var query = "SELECT * FROM \(quotedTable)" + let generator = FilterSQLGenerator(databaseType: databaseType) + let whereClause = generator.generateWhereClause(from: filters, logicMode: logicMode) + if !whereClause.isEmpty { + query += " \(whereClause)" + } + let orderBy = buildOrderByClause(sortState: sortState, columns: columns) + ?? "ORDER BY 1" + query += " \(orderBy) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY" + return query + } + + private func buildOracleQuickSearchQuery( + tableName: String, + searchText: String, + columns: [String], + sortState: SortState?, + limit: Int, + offset: Int + ) -> String { + let quotedTable = databaseType.quoteIdentifier(tableName) + var query = "SELECT * FROM \(quotedTable)" + let escapedSearch = escapeForLike(searchText) + let conditions = columns.map { column -> String in + let quotedColumn = databaseType.quoteIdentifier(column) + return buildLikeCondition(column: quotedColumn, searchText: escapedSearch) + } + if !conditions.isEmpty { + query += " WHERE (" + conditions.joined(separator: " OR ") + ")" + } + let orderBy = buildOrderByClause(sortState: sortState, columns: columns) + ?? "ORDER BY 1" + query += " \(orderBy) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY" + return query + } + + private func buildOracleCombinedQuery( + tableName: String, + filters: [TableFilter], + logicMode: FilterLogicMode, + searchText: String, + searchColumns: [String], + sortState: SortState?, + columns: [String], + limit: Int, + offset: Int + ) -> String { + let quotedTable = databaseType.quoteIdentifier(tableName) + var query = "SELECT * FROM \(quotedTable)" + let generator = FilterSQLGenerator(databaseType: databaseType) + let filterConditions = generator.generateConditions(from: filters, logicMode: logicMode) + let escapedSearch = escapeForLike(searchText) + let searchConditions = searchColumns.map { column -> String in + let quotedColumn = databaseType.quoteIdentifier(column) + return buildLikeCondition(column: quotedColumn, searchText: escapedSearch) + } + let searchClause = searchConditions.isEmpty ? "" : "(" + searchConditions.joined(separator: " OR ") + ")" + var whereParts: [String] = [] + if !filterConditions.isEmpty { + whereParts.append("(\(filterConditions))") + } + if !searchClause.isEmpty { + whereParts.append(searchClause) + } + if !whereParts.isEmpty { + query += " WHERE " + whereParts.joined(separator: " AND ") + } + let orderBy = buildOrderByClause(sortState: sortState, columns: columns) + ?? "ORDER BY 1" + query += " \(orderBy) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY" + return query + } } diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index 4ae3c9d5..c604ed74 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -362,6 +362,9 @@ private struct StoredConnection: Codable { // MSSQL schema let mssqlSchema: String? + // Oracle service name + let oracleServiceName: String? + init(from connection: DatabaseConnection) { self.id = connection.id self.name = connection.name @@ -400,6 +403,9 @@ private struct StoredConnection: Codable { // MSSQL schema self.mssqlSchema = connection.mssqlSchema + + // Oracle service name + self.oracleServiceName = connection.oracleServiceName } // Custom decoder to handle migration from old format @@ -438,6 +444,7 @@ private struct StoredConnection: Codable { isReadOnly = try container.decodeIfPresent(Bool.self, forKey: .isReadOnly) ?? false aiPolicy = try container.decodeIfPresent(String.self, forKey: .aiPolicy) mssqlSchema = try container.decodeIfPresent(String.self, forKey: .mssqlSchema) + oracleServiceName = try container.decodeIfPresent(String.self, forKey: .oracleServiceName) } func toConnection() -> DatabaseConnection { @@ -479,7 +486,8 @@ private struct StoredConnection: Codable { groupId: parsedGroupId, isReadOnly: isReadOnly, aiPolicy: parsedAIPolicy, - mssqlSchema: mssqlSchema + mssqlSchema: mssqlSchema, + oracleServiceName: oracleServiceName ) } } diff --git a/TablePro/Core/Utilities/ConnectionURLFormatter.swift b/TablePro/Core/Utilities/ConnectionURLFormatter.swift index 6dbcc69e..6d8a95d6 100644 --- a/TablePro/Core/Utilities/ConnectionURLFormatter.swift +++ b/TablePro/Core/Utilities/ConnectionURLFormatter.swift @@ -32,6 +32,7 @@ struct ConnectionURLFormatter { case .mongodb: return "mongodb" case .redis: return "redis" case .mssql: return "sqlserver" + case .oracle: return "oracle" } } @@ -73,7 +74,10 @@ struct ConnectionURLFormatter { result += ":\(connection.port)" } - result += "/\(connection.database)" + let sshPathComponent = connection.type == .oracle + ? (connection.oracleServiceName ?? connection.database) + : connection.database + result += "/\(sshPathComponent)" let query = buildQueryString(connection) if !query.isEmpty { @@ -103,7 +107,10 @@ struct ConnectionURLFormatter { result += ":\(connection.port)" } - result += "/\(connection.database)" + let pathComponent = connection.type == .oracle + ? (connection.oracleServiceName ?? connection.database) + : connection.database + result += "/\(pathComponent)" let query = buildQueryString(connection) if !query.isEmpty { diff --git a/TablePro/Core/Utilities/ConnectionURLParser.swift b/TablePro/Core/Utilities/ConnectionURLParser.swift index dd5ece66..b4c8e38e 100644 --- a/TablePro/Core/Utilities/ConnectionURLParser.swift +++ b/TablePro/Core/Utilities/ConnectionURLParser.swift @@ -31,14 +31,16 @@ struct ParsedConnectionURL { let filterOperation: String? let filterValue: String? let filterCondition: String? + let oracleServiceName: String? var suggestedName: String { if let connectionName, !connectionName.isEmpty { return connectionName } let typeName = type.rawValue - if !database.isEmpty { - return "\(typeName) \(host)/\(database)" + let displayDatabase = database.isEmpty ? (oracleServiceName ?? "") : database + if !displayDatabase.isEmpty { + return "\(typeName) \(host)/\(displayDatabase)" } if !host.isEmpty { return "\(typeName) \(host)" @@ -104,6 +106,8 @@ struct ConnectionURLParser { dbType = .redis case "sqlserver", "mssql", "jdbc:sqlserver": dbType = .mssql + case "oracle", "jdbc:oracle:thin": + dbType = .oracle default: return .failure(.unsupportedScheme(scheme)) } @@ -135,7 +139,8 @@ struct ConnectionURLParser { filterColumn: nil, filterOperation: nil, filterValue: nil, - filterCondition: nil + filterCondition: nil, + oracleServiceName: nil )) } @@ -181,6 +186,13 @@ struct ConnectionURLParser { } } + // Oracle-specific: path component is the service name, not the database name + var oracleServiceName: String? + if dbType == .oracle && !database.isEmpty { + oracleServiceName = database + database = "" + } + return .success(ParsedConnectionURL( type: dbType, host: host, @@ -206,7 +218,8 @@ struct ConnectionURLParser { filterColumn: ext.filterColumn, filterOperation: ext.filterOperation, filterValue: ext.filterValue, - filterCondition: ext.filterCondition + filterCondition: ext.filterCondition, + oracleServiceName: oracleServiceName )) } @@ -304,6 +317,13 @@ struct ConnectionURLParser { let ext = parseSSHQueryString(queryString) + // Oracle-specific: path component is the service name, not the database name + var oracleServiceName: String? + if dbType == .oracle && !database.isEmpty { + oracleServiceName = database + database = "" + } + return .success(ParsedConnectionURL( type: dbType, host: host, @@ -329,7 +349,8 @@ struct ConnectionURLParser { filterColumn: ext.filterColumn, filterOperation: ext.filterOperation, filterValue: ext.filterValue, - filterCondition: ext.filterCondition + filterCondition: ext.filterCondition, + oracleServiceName: oracleServiceName )) } diff --git a/TablePro/Core/Utilities/SQLParameterInliner.swift b/TablePro/Core/Utilities/SQLParameterInliner.swift index b02d36df..472fcdfe 100644 --- a/TablePro/Core/Utilities/SQLParameterInliner.swift +++ b/TablePro/Core/Utilities/SQLParameterInliner.swift @@ -21,7 +21,7 @@ struct SQLParameterInliner { switch databaseType { case .postgresql, .redshift: return inlineDollarPlaceholders(statement.sql, parameters: statement.parameters) - case .mysql, .mariadb, .sqlite, .mongodb, .redis, .mssql: + case .mysql, .mariadb, .sqlite, .mongodb, .redis, .mssql, .oracle: return inlineQuestionMarkPlaceholders(statement.sql, parameters: statement.parameters) } } diff --git a/TablePro/Models/DatabaseConnection.swift b/TablePro/Models/DatabaseConnection.swift index 9fb2b7b0..be9b0926 100644 --- a/TablePro/Models/DatabaseConnection.swift +++ b/TablePro/Models/DatabaseConnection.swift @@ -112,6 +112,7 @@ enum DatabaseType: String, CaseIterable, Identifiable, Codable { case mongodb = "MongoDB" case redis = "Redis" case mssql = "SQL Server" + case oracle = "Oracle" var id: String { rawValue } @@ -134,6 +135,8 @@ enum DatabaseType: String, CaseIterable, Identifiable, Codable { return "redis-icon" case .mssql: return "mssql-icon" + case .oracle: + return "oracle-icon" } } @@ -147,6 +150,7 @@ enum DatabaseType: String, CaseIterable, Identifiable, Codable { case .mongodb: return 27_017 case .redis: return 6_379 case .mssql: return 1_433 + case .oracle: return 1_521 } } @@ -155,7 +159,7 @@ enum DatabaseType: String, CaseIterable, Identifiable, Codable { /// MongoDB and SQLite commonly run without authentication. var requiresAuthentication: Bool { switch self { - case .mysql, .mariadb, .postgresql, .redshift, .mssql: return true + case .mysql, .mariadb, .postgresql, .redshift, .mssql, .oracle: return true case .sqlite, .mongodb, .redis: return false } } @@ -163,7 +167,7 @@ enum DatabaseType: String, CaseIterable, Identifiable, Codable { /// Whether this database type supports foreign key constraints var supportsForeignKeys: Bool { switch self { - case .mysql, .mariadb, .postgresql, .sqlite, .redshift, .mssql: + case .mysql, .mariadb, .postgresql, .sqlite, .redshift, .mssql, .oracle: return true case .mongodb, .redis: return false @@ -175,6 +179,7 @@ enum DatabaseType: String, CaseIterable, Identifiable, Codable { case .mysql, .mariadb: return "START TRANSACTION" case .postgresql, .redshift, .sqlite: return "BEGIN" case .mssql: return "BEGIN TRANSACTION" + case .oracle: return "" case .mongodb, .redis: return "" } } @@ -182,7 +187,7 @@ enum DatabaseType: String, CaseIterable, Identifiable, Codable { /// Whether this database type supports SQL-based schema editing (ALTER TABLE etc.) var supportsSchemaEditing: Bool { switch self { - case .mysql, .mariadb, .postgresql, .sqlite, .mssql: + case .mysql, .mariadb, .postgresql, .sqlite, .mssql, .oracle: return true case .redshift, .mongodb, .redis: return false @@ -195,7 +200,7 @@ enum DatabaseType: String, CaseIterable, Identifiable, Codable { switch self { case .mysql, .mariadb, .sqlite: return "`" - case .postgresql, .redshift, .mongodb, .redis: + case .postgresql, .redshift, .mongodb, .redis, .oracle: return "\"" case .mssql: return "[" @@ -289,6 +294,7 @@ struct DatabaseConnection: Identifiable, Hashable { var mongoWriteConcern: String? var redisDatabase: Int? var mssqlSchema: String? + var oracleServiceName: String? init( id: UUID = UUID(), @@ -308,7 +314,8 @@ struct DatabaseConnection: Identifiable, Hashable { mongoReadPreference: String? = nil, mongoWriteConcern: String? = nil, redisDatabase: Int? = nil, - mssqlSchema: String? = nil + mssqlSchema: String? = nil, + oracleServiceName: String? = nil ) { self.id = id self.name = name @@ -328,6 +335,7 @@ struct DatabaseConnection: Identifiable, Hashable { self.mongoWriteConcern = mongoWriteConcern self.redisDatabase = redisDatabase self.mssqlSchema = mssqlSchema + self.oracleServiceName = oracleServiceName } /// Returns the display color (custom color or database type color) diff --git a/TablePro/Models/MultiRowEditState.swift b/TablePro/Models/MultiRowEditState.swift index 8e583f63..a85c434d 100644 --- a/TablePro/Models/MultiRowEditState.swift +++ b/TablePro/Models/MultiRowEditState.swift @@ -209,6 +209,16 @@ final class MultiRowEditState { } } + /// Release all data to free memory on disconnect + func releaseData() { + fields = [] + onFieldChanged = nil + selectedRowIndices = [] + allRows = [] + columns = [] + columnTypes = [] + } + /// Get all edited fields with their new values func getEditedFields() -> [(columnIndex: Int, columnName: String, newValue: String?)] { fields.compactMap { field in diff --git a/TablePro/Models/QueryTab.swift b/TablePro/Models/QueryTab.swift index e931eb0b..e18d19f8 100644 --- a/TablePro/Models/QueryTab.swift +++ b/TablePro/Models/QueryTab.swift @@ -526,6 +526,9 @@ final class QueryTabManager { } else if databaseType == .mssql { let quotedName = databaseType.quoteIdentifier(tableName) query = "SELECT * FROM \(quotedName) ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT \(pageSize) ROWS ONLY;" + } else if databaseType == .oracle { + let quotedName = databaseType.quoteIdentifier(tableName) + query = "SELECT * FROM \(quotedName) ORDER BY 1 OFFSET 0 ROWS FETCH NEXT \(pageSize) ROWS ONLY;" } else { let quotedName = databaseType.quoteIdentifier(tableName) query = "SELECT * FROM \(quotedName) LIMIT \(pageSize);" @@ -566,6 +569,9 @@ final class QueryTabManager { } else if databaseType == .mssql { let quotedName = databaseType.quoteIdentifier(tableName) query = "SELECT * FROM \(quotedName) ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT \(pageSize) ROWS ONLY;" + } else if databaseType == .oracle { + let quotedName = databaseType.quoteIdentifier(tableName) + query = "SELECT * FROM \(quotedName) ORDER BY 1 OFFSET 0 ROWS FETCH NEXT \(pageSize) ROWS ONLY;" } else { let quotedName = databaseType.quoteIdentifier(tableName) query = "SELECT * FROM \(quotedName) LIMIT \(pageSize);" diff --git a/TablePro/Models/RightPanelState.swift b/TablePro/Models/RightPanelState.swift index 2ecdfc7a..8e2590b2 100644 --- a/TablePro/Models/RightPanelState.swift +++ b/TablePro/Models/RightPanelState.swift @@ -42,6 +42,15 @@ import Foundation ) } + /// Release all heavy data on disconnect so memory drops + /// even if AppKit keeps the window alive. + func teardown() { + onSave = nil + aiViewModel.clearSessionData() + editState.releaseData() + NotificationCenter.default.removeObserver(self) // swiftlint:disable:this notification_center_detachment + } + deinit { NotificationCenter.default.removeObserver(self) } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 1d637cd9..67214c08 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -1039,6 +1039,9 @@ } } } + }, + "Agent Socket" : { + }, "AI" : { "localizations" : { @@ -5140,6 +5143,9 @@ } } } + }, + "Keys are provided by the SSH agent (e.g. 1Password, ssh-agent)." : { + }, "Language:" : { "localizations" : { @@ -5180,6 +5186,9 @@ } } } + }, + "Leave empty for SSH_AUTH_SOCK" : { + }, "Length" : { "extractionState" : "stale", @@ -6599,6 +6608,9 @@ } } } + }, + "Oracle" : { + }, "Orange" : { "localizations" : { @@ -8089,6 +8101,9 @@ } } } + }, + "Service Name" : { + }, "Set DEFAULT" : { "localizations" : { @@ -8390,6 +8405,9 @@ } } } + }, + "SSH Agent" : { + }, "SSH authentication failed. Check your credentials or private key." : { "localizations" : { diff --git a/TablePro/Theme/Theme.swift b/TablePro/Theme/Theme.swift index 9bff0d20..18d978a5 100644 --- a/TablePro/Theme/Theme.swift +++ b/TablePro/Theme/Theme.swift @@ -22,6 +22,7 @@ enum Theme { static let redshiftColor = Color(red: 0.13, green: 0.36, blue: 0.59) static let redisColor = Color(red: 0.86, green: 0.22, blue: 0.18) // #DC382D static let mssqlColor = Color(red: 0.89, green: 0.27, blue: 0.09) + static let oracleColor = Color(red: 0.76, green: 0.09, blue: 0.07) // #C3160B Oracle red // MARK: - Semantic Colors @@ -114,6 +115,8 @@ extension DatabaseType { return Theme.redisColor case .mssql: return Theme.mssqlColor + case .oracle: + return Theme.oracleColor } } } diff --git a/TablePro/ViewModels/AIChatViewModel.swift b/TablePro/ViewModels/AIChatViewModel.swift index 7654a4d5..78811f85 100644 --- a/TablePro/ViewModels/AIChatViewModel.swift +++ b/TablePro/ViewModels/AIChatViewModel.swift @@ -197,6 +197,28 @@ final class AIChatViewModel { errorMessage = nil } + /// Release all session-specific data to free memory on disconnect. + /// Unlike `clearConversation()`, this does not delete persisted history. + func clearSessionData() { + streamingTask?.cancel() + streamingTask = nil + schemaProvider = nil + connection = nil + tables = [] + columnsByTable = [:] + foreignKeysByTable = [:] + currentQuery = nil + queryResults = nil + messages = [] + errorMessage = nil + lastMessageFailed = false + activeConversationID = nil + sessionApprovedConnections = [] + isStreaming = false + streamingAssistantID = nil + pendingFeature = nil + } + /// Delete a conversation func deleteConversation(_ id: UUID) { chatStorage.delete(id) diff --git a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift index e98873bf..928863bb 100644 --- a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift +++ b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift @@ -188,6 +188,8 @@ final class DatabaseSwitcherViewModel { return false case .mssql: return ["master", "tempdb", "model", "msdb"].contains(name) + case .oracle: + return ["SYS", "SYSTEM", "OUTLN", "DBSNMP", "APPQOSSYS", "WMSYS", "XDB"].contains(name) } } } diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 05f65862..b43dabb5 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -72,6 +72,9 @@ struct ConnectionFormView: View { // MSSQL-specific settings @State private var mssqlSchema: String = "dbo" + // Oracle-specific settings + @State private var oracleServiceName: String = "" + @State private var isTesting: Bool = false @State private var testResult: TestResult? @@ -473,6 +476,16 @@ struct ConnectionFormView: View { } } + if type == .oracle { + Section(String(localized: "Oracle")) { + TextField(String(localized: "Service Name"), text: Binding( + get: { oracleServiceName }, + set: { oracleServiceName = $0 } + )) + .textFieldStyle(.roundedBorder) + } + } + Section(String(localized: "AI")) { Picker(String(localized: "AI Policy"), selection: $aiPolicy) { Text(String(localized: "Use Default")) @@ -564,6 +577,7 @@ struct ConnectionFormView: View { case .mongodb: return "27017" case .redis: return "6379" case .mssql: return "1433" + case .oracle: return "1521" } } @@ -635,6 +649,9 @@ struct ConnectionFormView: View { // Load MSSQL settings mssqlSchema = existing.mssqlSchema ?? "dbo" + // Load Oracle settings + oracleServiceName = existing.oracleServiceName ?? "" + // Load passwords from Keychain if let savedSSHPassword = storage.loadSSHPassword(for: existing.id) { sshPassword = savedSSHPassword @@ -694,7 +711,8 @@ struct ConnectionFormView: View { aiPolicy: aiPolicy, mongoReadPreference: mongoReadPreference.isEmpty ? nil : mongoReadPreference, mongoWriteConcern: mongoWriteConcern.isEmpty ? nil : mongoWriteConcern, - mssqlSchema: mssqlSchema.isEmpty ? nil : mssqlSchema + mssqlSchema: mssqlSchema.isEmpty ? nil : mssqlSchema, + oracleServiceName: oracleServiceName.isEmpty ? nil : oracleServiceName ) // Save passwords to Keychain @@ -793,7 +811,8 @@ struct ConnectionFormView: View { groupId: selectedGroupId, mongoReadPreference: mongoReadPreference.isEmpty ? nil : mongoReadPreference, mongoWriteConcern: mongoWriteConcern.isEmpty ? nil : mongoWriteConcern, - mssqlSchema: mssqlSchema.isEmpty ? nil : mssqlSchema + mssqlSchema: mssqlSchema.isEmpty ? nil : mssqlSchema, + oracleServiceName: oracleServiceName.isEmpty ? nil : oracleServiceName ) Task { diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index e6691752..88247217 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -531,6 +531,28 @@ struct ExportDialog: View { return item1.name < item2.name } + case .oracle: + // Oracle: fetch schemas (users) and their tables + let schemas = try await driver.fetchSchemas() + for schema in schemas { + let tables = try await fetchTablesForSchema(schema, driver: driver) + let tableItems = tables.map { table in + ExportTableItem( + name: table.name, + databaseName: schema, + type: table.type, + isSelected: preselectedTables.contains(table.name) + ) + } + if !tableItems.isEmpty { + items.append(ExportDatabaseItem( + name: schema, + tables: tableItems, + isExpanded: schema == connection.username.uppercased() + )) + } + } + case .mysql, .mariadb: // MySQL/MariaDB: fetch all databases and their tables let databases = try await driver.fetchDatabases() @@ -591,7 +613,25 @@ struct ExportDialog: View { } private func fetchTablesForSchema(_ schema: String, driver: DatabaseDriver) async throws -> [TableInfo] { - // Fetch tables from information_schema and filter by schema in Swift to avoid SQL interpolation. + // Oracle does not have information_schema — use ALL_TABLES/ALL_VIEWS + if connection.type == .oracle { + let escapedSchema = schema.replacingOccurrences(of: "'", with: "''") + let query = """ + SELECT TABLE_NAME, 'BASE TABLE' AS TABLE_TYPE FROM ALL_TABLES WHERE OWNER = '\(escapedSchema)' + UNION ALL + SELECT VIEW_NAME, 'VIEW' FROM ALL_VIEWS WHERE OWNER = '\(escapedSchema)' + ORDER BY 1 + """ + let result = try await driver.execute(query: query) + return result.rows.compactMap { row in + guard let name = row[safe: 0] ?? nil else { return nil } + let typeStr = (row[safe: 1] ?? nil) ?? "BASE TABLE" + let type: TableInfo.TableType = typeStr.uppercased().contains("VIEW") ? .view : .table + return TableInfo(name: name, type: type, rowCount: nil) + } + } + + // MSSQL / PostgreSQL / Redshift: use information_schema let query = """ SELECT table_schema, table_name, table_type FROM information_schema.tables diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index d6159b3f..c01ce0e3 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -224,6 +224,23 @@ extension MainContentCoordinator { GROUP BY s.name, t.name, p.rows, v.object_id ORDER BY t.name """ + case .oracle: + let schema: String + if let oracleDriver = DatabaseManager.shared.driver(for: connectionId) as? OracleDriver { + schema = oracleDriver.escapedSchema + } else { + schema = "SYSTEM" + } + sql = """ + SELECT + OWNER as schema_name, + TABLE_NAME as name, + 'TABLE' as kind, + NUM_ROWS as estimated_rows + FROM ALL_TABLES + WHERE OWNER = '\(schema)' + ORDER BY TABLE_NAME + """ case .mongodb: tabManager.addTab( initialQuery: "db.runCommand({\"listCollections\": 1, \"nameOnly\": false})", @@ -353,6 +370,29 @@ extension MainContentCoordinator { // Force sidebar reload — posting .refreshData ensures loadTables() runs // even when session.tables was already [] (e.g. switching from empty schema back to public) + NotificationCenter.default.post(name: .refreshData, object: nil) + } else if connection.type == .oracle { + if let oracleDriver = driver as? OracleDriver { + try await oracleDriver.switchSchema(to: database) + } + + if let oracleMeta = DatabaseManager.shared.metadataDriver(for: connectionId) as? OracleDriver { + try? await oracleMeta.switchSchema(to: database) + } + + DatabaseManager.shared.updateSession(connectionId) { session in + session.currentSchema = database + session.tables = [] + } + + toolbarState.databaseName = database + + closeSiblingNativeWindows() + tabManager.tabs = [] + tabManager.selectedTabId = nil + + await loadSchema() + NotificationCenter.default.post(name: .refreshData, object: nil) } else if connection.type == .mssql { if let mssqlDriver = driver as? MSSQLDriver { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift index 4cdaffef..e4b6634f 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift @@ -33,7 +33,7 @@ extension MainContentCoordinator { let sortedDeletes = deletes.sorted() // Check if any operation needs FK disabled (not applicable to PostgreSQL or MSSQL) - let needsDisableFK = includeFKHandling && dbType != .postgresql && dbType != .mssql && truncates.union(deletes).contains { tableName in + let needsDisableFK = includeFKHandling && dbType != .postgresql && dbType != .mssql && dbType != .oracle && truncates.union(deletes).contains { tableName in options[tableName]?.ignoreForeignKeys == true } @@ -84,7 +84,7 @@ extension MainContentCoordinator { func fkDisableStatements(for dbType: DatabaseType) -> [String] { switch dbType { case .mysql, .mariadb: return ["SET FOREIGN_KEY_CHECKS=0"] - case .postgresql, .redshift, .mongodb, .redis, .mssql: return [] + case .postgresql, .redshift, .mongodb, .redis, .mssql, .oracle: return [] case .sqlite: return ["PRAGMA foreign_keys = OFF"] } } @@ -94,7 +94,7 @@ extension MainContentCoordinator { switch dbType { case .mysql, .mariadb: return ["SET FOREIGN_KEY_CHECKS=1"] - case .postgresql, .redshift, .mongodb, .redis, .mssql: + case .postgresql, .redshift, .mongodb, .redis, .mssql, .oracle: return [] case .sqlite: return ["PRAGMA foreign_keys = ON"] @@ -112,7 +112,7 @@ extension MainContentCoordinator { case .postgresql, .redshift: let cascade = options.cascade ? " CASCADE" : "" return ["TRUNCATE TABLE \(quotedName)\(cascade)"] - case .mssql: + case .mssql, .oracle: return ["TRUNCATE TABLE \(quotedName)"] case .sqlite: // DELETE FROM + reset auto-increment counter for true TRUNCATE semantics. @@ -141,7 +141,7 @@ extension MainContentCoordinator { switch dbType { case .postgresql, .redshift: return "DROP \(keyword) \(quotedName)\(options.cascade ? " CASCADE" : "")" - case .mysql, .mariadb, .sqlite, .mssql: + case .mysql, .mariadb, .sqlite, .mssql, .oracle: return "DROP \(keyword) \(quotedName)" case .mongodb: let escaped = tableName.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 626f2436..d9645952 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -293,6 +293,9 @@ final class MainContentCommandActions { keyWindow.close() } else { // Last tab with content — clear tabs to show empty state instead of closing + for tab in coordinator?.tabManager.tabs ?? [] { + tab.rowBuffer.evict() + } coordinator?.tabManager.tabs.removeAll() coordinator?.tabManager.selectedTabId = nil AppState.shared.isCurrentTabEditable = false @@ -313,6 +316,8 @@ final class MainContentCommandActions { template = "CREATE VIEW IF NOT EXISTS view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;" case .mssql: template = "CREATE OR ALTER VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;" + case .oracle: + template = "CREATE OR REPLACE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;" case .mongodb: template = "db.createView(\"view_name\", \"source_collection\", [\n {\"$match\": {}},\n {\"$project\": {\"_id\": 1}}\n])" case .redis: @@ -617,6 +622,8 @@ final class MainContentCommandActions { fallbackSQL = "-- SQLite does not support ALTER VIEW. Drop and recreate:\nDROP VIEW IF EXISTS \(viewName);\nCREATE VIEW \(viewName) AS\nSELECT * FROM table_name;" case .mssql: fallbackSQL = "CREATE OR ALTER VIEW \(viewName) AS\n-- Could not fetch view definition: \(error.localizedDescription)\nSELECT * FROM table_name;" + case .oracle: + fallbackSQL = "CREATE OR REPLACE VIEW \(viewName) AS\n-- Could not fetch view definition: \(error.localizedDescription)\nSELECT * FROM table_name;" case .mongodb: fallbackSQL = "db.runCommand({\"collMod\": \"\(viewName)\", \"viewOn\": \"source_collection\", \"pipeline\": [{\"$match\": {}}]})" case .redis: diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index a896a4fa..04709801 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -79,7 +79,7 @@ final class MainContentCoordinator { @ObservationIgnored private var terminationObserver: NSObjectProtocol? /// Set during handleTabChange to suppress redundant onChange(of: resultColumns) reconfiguration - internal var isHandlingTabSwitch = false + @ObservationIgnored internal var isHandlingTabSwitch = false /// True while a database switch is in progress. Guards against /// side-effect window creation during the switch cascade. @@ -205,6 +205,9 @@ final class MainContentCoordinator { } querySortCache.removeAll() + tabManager.tabs.removeAll() + tabManager.selectedTabId = nil + SchemaProviderRegistry.shared.release(for: connection.id) SchemaProviderRegistry.shared.purgeUnused() } @@ -300,15 +303,15 @@ final class MainContentCoordinator { /// Default row limit for query tabs to prevent unbounded result sets private static let defaultQueryLimit = 10_000 - /// Pre-compiled regex for detecting existing LIMIT clause in SELECT queries + /// Pre-compiled regex for detecting existing LIMIT/FETCH/TOP clause in SELECT queries private static let limitClauseRegex = try? NSRegularExpression( - pattern: "\\bLIMIT\\s+\\d+", + pattern: "\\b(?:LIMIT\\s+\\d+|FETCH\\s+(?:FIRST|NEXT)\\s+\\d+\\s+ROWS?\\s+ONLY|TOP\\s+\\d+)", options: .caseInsensitive ) /// Pre-compiled regex for extracting table name from SELECT queries private static let tableNameRegex = try? NSRegularExpression( - pattern: #"(?i)^\s*SELECT\s+.+?\s+FROM\s+(?:\[(\w+)\]|[`"]?(\w+)[`"]?)\s*(?:WHERE|ORDER|LIMIT|GROUP|HAVING|OFFSET|$|;)"#, + pattern: #"(?i)^\s*SELECT\s+.+?\s+FROM\s+(?:\[([^\]]+)\]|[`"]([^`"]+)[`"]|([\w$]+))\s*(?:WHERE|ORDER|LIMIT|GROUP|HAVING|OFFSET|FETCH|$|;)"#, options: [] ) @@ -439,7 +442,7 @@ final class MainContentCoordinator { // Build database-specific EXPLAIN prefix let explainSQL: String switch connection.type { - case .mssql: + case .mssql, .oracle: return case .sqlite: explainSQL = "EXPLAIN QUERY PLAN \(stmt)" @@ -482,7 +485,7 @@ final class MainContentCoordinator { // DAT-1: For query tabs, auto-append LIMIT if the SQL is a SELECT without one let effectiveSQL: String if tab.tabType == .query { - effectiveSQL = Self.addLimitIfNeeded(to: sql, limit: Self.defaultQueryLimit) + effectiveSQL = Self.addLimitIfNeeded(to: sql, limit: Self.defaultQueryLimit, dbType: connection.type) } else { effectiveSQL = sql } @@ -595,14 +598,24 @@ final class MainContentCoordinator { } // Phase 2: Background exact COUNT + enum values. - if isEditable, let tableName = tableName, needsMetadataFetch { - launchPhase2Work( - tableName: tableName, - tabId: tabId, - capturedGeneration: capturedGeneration, - connectionType: conn.type, - schemaResult: schemaResult - ) + if isEditable, let tableName = tableName { + if needsMetadataFetch { + launchPhase2Work( + tableName: tableName, + tabId: tabId, + capturedGeneration: capturedGeneration, + connectionType: conn.type, + schemaResult: schemaResult + ) + } else { + // Metadata cached but still need exact COUNT for pagination + launchPhase2Count( + tableName: tableName, + tabId: tabId, + capturedGeneration: capturedGeneration, + connectionType: conn.type + ) + } } else if !isEditable || tableName == nil { await MainActor.run { [weak self] in guard let self else { return } @@ -672,26 +685,39 @@ final class MainContentCoordinator { // MARK: - Query Limit Protection - /// Appends a LIMIT clause to SELECT queries that don't already have one. - /// Protects query tabs from unbounded result sets (e.g., SELECT * FROM million_row_table). - private static func addLimitIfNeeded(to sql: String, limit: Int) -> String { + /// Appends a row-limiting clause to SELECT queries that don't already have one. + /// Uses database-appropriate syntax (LIMIT, FETCH FIRST, TOP). + private static func addLimitIfNeeded(to sql: String, limit: Int, dbType: DatabaseType) -> String { let trimmed = sql.trimmingCharacters(in: .whitespacesAndNewlines) let uppercased = trimmed.uppercased() // Only apply to SELECT statements guard uppercased.hasPrefix("SELECT ") else { return sql } - // Check if query already has a LIMIT clause + // Skip for databases that don't support row limiting via SQL + guard dbType != .mongodb, dbType != .redis else { return sql } + + // Check if query already has a LIMIT/FETCH/TOP clause let range = NSRange(trimmed.startIndex..., in: trimmed) if limitClauseRegex?.firstMatch(in: trimmed, options: [], range: range) != nil { return sql } - // Strip trailing semicolon, append LIMIT, and re-add semicolon + // Strip trailing semicolon let withoutSemicolon = trimmed.hasSuffix(";") ? String(trimmed.dropLast()).trimmingCharacters(in: .whitespacesAndNewlines) : trimmed - return "\(withoutSemicolon) LIMIT \(limit)" + + switch dbType { + case .oracle: + return "\(withoutSemicolon) FETCH FIRST \(limit) ROWS ONLY" + case .mssql: + // MSSQL uses TOP in SELECT — inject after SELECT keyword + let afterSelect = withoutSemicolon.dropFirst(7) // drop "SELECT " + return "SELECT TOP \(limit) \(afterSelect)" + default: + return "\(withoutSemicolon) LIMIT \(limit)" + } } // MARK: - SQL Parsing @@ -702,7 +728,7 @@ final class MainContentCoordinator { // SQL: SELECT ... FROM tableName (group 1 = bracket-quoted, group 2 = plain/backtick/double-quote) if let regex = Self.tableNameRegex, let match = regex.firstMatch(in: sql, options: [], range: nsRange) { - for group in 1...2 { + for group in 1...3 { let r = match.range(at: group) if r.location != NSNotFound, let range = Range(r, in: sql) { return String(sql[range]) @@ -1321,6 +1347,37 @@ private extension MainContentCoordinator { } } + /// Launch only the exact COUNT(*) query (when metadata is already cached). + /// Does not guard on queryGeneration — the count is the same regardless of + /// which re-execution triggered it, and the repeated query issue means + /// generation is always stale by the time COUNT finishes. + private func launchPhase2Count( + tableName: String, + tabId: UUID, + capturedGeneration: Int, + connectionType: DatabaseType + ) { + let quotedTable = connectionType.quoteIdentifier(tableName) + Task { [weak self] in + guard let self else { return } + guard let mainDriver = DatabaseManager.shared.driver(for: connectionId) else { return } + let countResult = try? await mainDriver.execute( + query: "SELECT COUNT(*) FROM \(quotedTable)" + ) + if let firstRow = countResult?.rows.first, + let countStr = firstRow.first ?? nil, + let count = Int(countStr) { + await MainActor.run { [weak self] in + guard let self else { return } + if let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) { + tabManager.tabs[idx].pagination.totalRowCount = count + tabManager.tabs[idx].pagination.isApproximateRowCount = false + } + } + } + } + } + /// Handle query execution error: update tab state, record history, show alert func handleQueryExecutionError( _ error: Error, diff --git a/TablePro/Views/MainContentView.swift b/TablePro/Views/MainContentView.swift index 06405c64..70f9d92e 100644 --- a/TablePro/Views/MainContentView.swift +++ b/TablePro/Views/MainContentView.swift @@ -29,11 +29,11 @@ struct MainContentView: View { // MARK: - State Objects - @State private var tabManager: QueryTabManager - @State private var changeManager: DataChangeManager - @State private var filterStateManager: FilterStateManager - @State private var toolbarState: ConnectionToolbarState - @State var coordinator: MainContentCoordinator + let tabManager: QueryTabManager + let changeManager: DataChangeManager + let filterStateManager: FilterStateManager + let toolbarState: ConnectionToolbarState + let coordinator: MainContentCoordinator // MARK: - Local State @@ -72,7 +72,12 @@ struct MainContentView: View { pendingDeletes: Binding>, tableOperationOptions: Binding<[String: TableOperationOptions]>, inspectorContext: Binding, - rightPanelState: RightPanelState + rightPanelState: RightPanelState, + tabManager: QueryTabManager, + changeManager: DataChangeManager, + filterStateManager: FilterStateManager, + toolbarState: ConnectionToolbarState, + coordinator: MainContentCoordinator ) { self.connection = connection self.payload = payload @@ -84,81 +89,18 @@ struct MainContentView: View { self._tableOperationOptions = tableOperationOptions self._inspectorContext = inspectorContext self.rightPanelState = rightPanelState - - // Create state objects — each native window-tab gets its own instances - let tabMgr = QueryTabManager() - let changeMgr = DataChangeManager() - let filterMgr = FilterStateManager() - let toolbarSt = ConnectionToolbarState(connection: connection) - - // Eagerly populate version + state from existing session to avoid flash - if let session = DatabaseManager.shared.session(for: connection.id) { - toolbarSt.updateConnectionState(from: session.status) - if let driver = session.driver { - toolbarSt.databaseVersion = driver.serverVersion - } - } else if let driver = DatabaseManager.shared.driver(for: connection.id) { - toolbarSt.connectionState = .connected - toolbarSt.databaseVersion = driver.serverVersion - } - toolbarSt.hasCompletedSetup = true - - // Redis: set initial database name eagerly to avoid toolbar flash - if connection.type == .redis { - let dbIndex = connection.redisDatabase ?? Int(connection.database) ?? 0 - toolbarSt.databaseName = String(dbIndex) - } - - // Initialize single tab based on payload - if let payload, !payload.isConnectionOnly { - switch payload.tabType { - case .table: - if let tableName = payload.tableName { - tabMgr.addTableTab( - tableName: tableName, - databaseType: connection.type, - databaseName: payload.databaseName ?? connection.database - ) - if let index = tabMgr.selectedTabIndex { - tabMgr.tabs[index].isView = payload.isView - tabMgr.tabs[index].isEditable = !payload.isView - if payload.showStructure { - tabMgr.tabs[index].showStructure = true - } - } - } else { - tabMgr.addTab(databaseName: payload.databaseName ?? connection.database) - } - case .query: - tabMgr.addTab( - initialQuery: payload.initialQuery, - databaseName: payload.databaseName ?? connection.database - ) - } - } - // If payload is nil or connection-only, tab restoration handles it in initializeAndRestoreTabs() - - _tabManager = State(wrappedValue: tabMgr) - _changeManager = State(wrappedValue: changeMgr) - _filterStateManager = State(wrappedValue: filterMgr) - _toolbarState = State(wrappedValue: toolbarSt) - - // Create coordinator with all dependencies - _coordinator = State( - wrappedValue: MainContentCoordinator( - connection: connection, - tabManager: tabMgr, - changeManager: changeMgr, - filterStateManager: filterMgr, - toolbarState: toolbarSt - )) + self.tabManager = tabManager + self.changeManager = changeManager + self.filterStateManager = filterStateManager + self.toolbarState = toolbarState + self.coordinator = coordinator } // MARK: - Body var body: some View { bodyContent - .sheet(item: $coordinator.activeSheet) { sheet in + .sheet(item: Bindable(coordinator).activeSheet) { sheet in sheetContent(for: sheet) } .modifier(FocusedCommandActionsModifier(actions: commandActions)) @@ -291,6 +233,7 @@ struct MainContentView: View { // Window truly closed — teardown coordinator coordinator.teardown() + rightPanelState.teardown() // If no more windows for this connection, disconnect. // Tab state is NOT cleared here — it's preserved for next reconnect. @@ -996,6 +939,10 @@ private struct FocusedCommandActionsModifier: ViewModifier { // MARK: - Preview #Preview("With Connection") { + let state = SessionStateFactory.create( + connection: DatabaseConnection.sampleConnections[0], + payload: nil + ) MainContentView( connection: DatabaseConnection.sampleConnections[0], payload: nil, @@ -1006,7 +953,12 @@ private struct FocusedCommandActionsModifier: ViewModifier { pendingDeletes: .constant([]), tableOperationOptions: .constant([:]), inspectorContext: .constant(.empty), - rightPanelState: RightPanelState() + rightPanelState: RightPanelState(), + tabManager: state.tabManager, + changeManager: state.changeManager, + filterStateManager: state.filterStateManager, + toolbarState: state.toolbarState, + coordinator: state.coordinator ) .frame(width: 1_000, height: 600) } diff --git a/TablePro/Views/Results/ForeignKeyPopoverContentView.swift b/TablePro/Views/Results/ForeignKeyPopoverContentView.swift index b2986cbf..ada4e017 100644 --- a/TablePro/Views/Results/ForeignKeyPopoverContentView.swift +++ b/TablePro/Views/Results/ForeignKeyPopoverContentView.swift @@ -132,11 +132,18 @@ struct ForeignKeyPopoverContentView: View { } let query: String + let limitSuffix: String + switch databaseType { + case .oracle, .mssql: + limitSuffix = "OFFSET 0 ROWS FETCH NEXT \(Self.maxFetchRows) ROWS ONLY" + default: + limitSuffix = "LIMIT \(Self.maxFetchRows)" + } if let displayCol = displayColumn { let quotedDisplay = databaseType.quoteIdentifier(displayCol) - query = "SELECT \(quotedColumn), \(quotedDisplay) FROM \(quotedTable) ORDER BY \(quotedColumn) LIMIT \(Self.maxFetchRows)" + query = "SELECT \(quotedColumn), \(quotedDisplay) FROM \(quotedTable) ORDER BY \(quotedColumn) \(limitSuffix)" } else { - query = "SELECT DISTINCT \(quotedColumn) FROM \(quotedTable) ORDER BY \(quotedColumn) LIMIT \(Self.maxFetchRows)" + query = "SELECT DISTINCT \(quotedColumn) FROM \(quotedTable) ORDER BY \(quotedColumn) \(limitSuffix)" } do { diff --git a/TablePro/Views/Sidebar/TableOperationDialog.swift b/TablePro/Views/Sidebar/TableOperationDialog.swift index ba8cfc6c..b1b1d763 100644 --- a/TablePro/Views/Sidebar/TableOperationDialog.swift +++ b/TablePro/Views/Sidebar/TableOperationDialog.swift @@ -79,13 +79,16 @@ struct TableOperationDialog: View { /// PostgreSQL doesn't support globally disabling FK checks; use CASCADE instead private var ignoreFKDisabled: Bool { - databaseType == .postgresql || databaseType == .redshift + databaseType == .postgresql || databaseType == .redshift || databaseType == .oracle } private var ignoreFKDescription: String? { if databaseType == .postgresql || databaseType == .redshift { return "Not supported for PostgreSQL. Use CASCADE instead." } + if databaseType == .oracle { + return "Not supported for Oracle." + } return nil } diff --git a/TablePro/Views/Structure/TypePickerContentView.swift b/TablePro/Views/Structure/TypePickerContentView.swift index 2f780b10..1dcc52c5 100644 --- a/TablePro/Views/Structure/TypePickerContentView.swift +++ b/TablePro/Views/Structure/TypePickerContentView.swift @@ -25,6 +25,8 @@ enum DataTypeCategory: String, CaseIterable { return ["SMALLINT", "INTEGER", "BIGINT", "DECIMAL", "NUMERIC", "REAL", "DOUBLE PRECISION", "SMALLSERIAL", "SERIAL", "BIGSERIAL"] case .mssql: return ["TINYINT", "SMALLINT", "INT", "BIGINT", "DECIMAL", "NUMERIC", "FLOAT", "REAL", "MONEY", "SMALLMONEY", "BIT"] + case .oracle: + return ["NUMBER", "BINARY_FLOAT", "BINARY_DOUBLE", "INTEGER", "SMALLINT", "FLOAT"] case .sqlite: return ["INTEGER", "REAL", "NUMERIC"] case .mongodb: @@ -40,6 +42,8 @@ enum DataTypeCategory: String, CaseIterable { return ["CHAR", "VARCHAR", "TEXT"] case .mssql: return ["CHAR", "VARCHAR", "NCHAR", "NVARCHAR", "TEXT", "NTEXT"] + case .oracle: + return ["CHAR", "VARCHAR2", "NCHAR", "NVARCHAR2", "CLOB", "NCLOB", "LONG"] case .sqlite: return ["TEXT"] case .mongodb: @@ -55,6 +59,8 @@ enum DataTypeCategory: String, CaseIterable { return ["DATE", "TIME", "TIMESTAMP", "TIMESTAMPTZ", "INTERVAL"] case .mssql: return ["DATE", "TIME", "DATETIME", "DATETIME2", "SMALLDATETIME", "DATETIMEOFFSET"] + case .oracle: + return ["DATE", "TIMESTAMP", "TIMESTAMP WITH TIME ZONE", "TIMESTAMP WITH LOCAL TIME ZONE", "INTERVAL YEAR TO MONTH", "INTERVAL DAY TO SECOND"] case .sqlite: return ["DATE", "DATETIME"] case .mongodb: @@ -70,6 +76,8 @@ enum DataTypeCategory: String, CaseIterable { return ["BYTEA"] case .mssql: return ["BINARY", "VARBINARY", "IMAGE"] + case .oracle: + return ["BLOB", "RAW", "LONG RAW", "BFILE"] case .sqlite: return ["BLOB"] case .mongodb: @@ -85,6 +93,8 @@ enum DataTypeCategory: String, CaseIterable { return ["BOOLEAN", "UUID", "JSON", "JSONB", "ARRAY", "HSTORE", "INET", "CIDR", "MACADDR", "TSVECTOR", "TSQUERY"] case .mssql: return ["BIT", "UNIQUEIDENTIFIER", "XML", "SQL_VARIANT", "ROWVERSION", "HIERARCHYID"] + case .oracle: + return ["BOOLEAN", "ROWID", "UROWID", "XMLTYPE", "SDO_GEOMETRY"] case .sqlite: return ["BOOLEAN"] case .mongodb: diff --git a/TableProTests/Models/DatabaseTypeTests.swift b/TableProTests/Models/DatabaseTypeTests.swift index 33d2c5f0..4309e008 100644 --- a/TableProTests/Models/DatabaseTypeTests.swift +++ b/TableProTests/Models/DatabaseTypeTests.swift @@ -71,9 +71,9 @@ struct DatabaseTypeTests { #expect(result == "\"user\"\"s\"") } - @Test("CaseIterable count is 8") + @Test("CaseIterable count is 9") func testCaseIterableCount() { - #expect(DatabaseType.allCases.count == 8) + #expect(DatabaseType.allCases.count == 9) } @Test("Raw value matches display name", arguments: [ @@ -84,7 +84,8 @@ struct DatabaseTypeTests { (DatabaseType.mongodb, "MongoDB"), (DatabaseType.redis, "Redis"), (DatabaseType.redshift, "Redshift"), - (DatabaseType.mssql, "SQL Server") + (DatabaseType.mssql, "SQL Server"), + (DatabaseType.oracle, "Oracle") ]) func testRawValueMatchesDisplayName(dbType: DatabaseType, expectedRawValue: String) { #expect(dbType.rawValue == expectedRawValue) diff --git a/TableProTests/Models/RightPanelStateTests.swift b/TableProTests/Models/RightPanelStateTests.swift index a1b89aaf..a9c8b34d 100644 --- a/TableProTests/Models/RightPanelStateTests.swift +++ b/TableProTests/Models/RightPanelStateTests.swift @@ -53,4 +53,28 @@ struct RightPanelStateTests { #expect(state2.isPresented == true) UserDefaults.standard.removeObject(forKey: Self.key) } + + @Test("teardown nils schemaProvider on aiViewModel") + @MainActor + func teardown_nilsSchemaProvider() { + let state = RightPanelState() + state.aiViewModel.schemaProvider = SQLSchemaProvider() + #expect(state.aiViewModel.schemaProvider != nil) + + state.teardown() + + #expect(state.aiViewModel.schemaProvider == nil) + } + + @Test("teardown nils onSave closure") + @MainActor + func teardown_nilsOnSave() { + let state = RightPanelState() + state.onSave = { } + #expect(state.onSave != nil) + + state.teardown() + + #expect(state.onSave == nil) + } } diff --git a/TableProTests/Views/Main/SessionStateFactoryTests.swift b/TableProTests/Views/Main/SessionStateFactoryTests.swift new file mode 100644 index 00000000..5169ebd2 --- /dev/null +++ b/TableProTests/Views/Main/SessionStateFactoryTests.swift @@ -0,0 +1,173 @@ +// +// SessionStateFactoryTests.swift +// TableProTests +// +// Tests for SessionStateFactory, validating session state creation logic +// extracted from MainContentView.init. +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("SessionStateFactory") +struct SessionStateFactoryTests { + // MARK: - Helpers + + private func makePayload( + connectionId: UUID = UUID(), + tabType: TabType = .query, + tableName: String? = nil, + databaseName: String? = nil, + initialQuery: String? = nil, + isView: Bool = false, + showStructure: Bool = false + ) -> EditorTabPayload { + EditorTabPayload( + connectionId: connectionId, + tabType: tabType, + tableName: tableName, + databaseName: databaseName, + initialQuery: initialQuery, + isView: isView, + showStructure: showStructure + ) + } + + // MARK: - Tests + + @Test("Payload with tableName creates a table tab") + @MainActor + func payloadWithTableName_createsTableTab() { + let conn = TestFixtures.makeConnection() + let payload = makePayload( + connectionId: conn.id, + tabType: .table, + tableName: "users" + ) + + let state = SessionStateFactory.create(connection: conn, payload: payload) + + #expect(state.tabManager.tabs.count == 1) + #expect(state.tabManager.tabs.first?.tableName == "users") + #expect(state.tabManager.tabs.first?.tabType == .table) + } + + @Test("Payload with initialQuery creates a query tab with that text") + @MainActor + func payloadWithQuery_createsQueryTab() { + let conn = TestFixtures.makeConnection() + let query = "SELECT * FROM orders" + let payload = makePayload( + connectionId: conn.id, + tabType: .query, + initialQuery: query + ) + + let state = SessionStateFactory.create(connection: conn, payload: payload) + + #expect(state.tabManager.tabs.count == 1) + #expect(state.tabManager.tabs.first?.query == query) + #expect(state.tabManager.tabs.first?.tabType == .query) + } + + @Test("Payload with showStructure sets showStructure on the tab") + @MainActor + func payloadWithStructure_setsShowStructure() { + let conn = TestFixtures.makeConnection() + let payload = makePayload( + connectionId: conn.id, + tabType: .table, + tableName: "users", + showStructure: true + ) + + let state = SessionStateFactory.create(connection: conn, payload: payload) + + guard let tab = state.tabManager.tabs.first else { + Issue.record("Expected at least one tab") + return + } + #expect(tab.showStructure == true) + } + + @Test("Payload with isView sets isView and clears isEditable") + @MainActor + func payloadWithView_setsIsViewAndNotEditable() { + let conn = TestFixtures.makeConnection() + let payload = makePayload( + connectionId: conn.id, + tabType: .table, + tableName: "user_view", + isView: true + ) + + let state = SessionStateFactory.create(connection: conn, payload: payload) + + guard let tab = state.tabManager.tabs.first else { + Issue.record("Expected at least one tab") + return + } + #expect(tab.isView == true) + #expect(tab.isEditable == false) + } + + @Test("Nil payload creates empty tab manager") + @MainActor + func nilPayload_createsEmptyTabManager() { + let conn = TestFixtures.makeConnection() + + let state = SessionStateFactory.create(connection: conn, payload: nil) + + #expect(state.tabManager.tabs.isEmpty) + } + + @Test("Connection-only payload creates empty tab manager") + @MainActor + func connectionOnlyPayload_createsEmptyTabManager() { + let conn = TestFixtures.makeConnection() + // isConnectionOnly is true when tabType == .query, tableName == nil, initialQuery == nil + let payload = makePayload(connectionId: conn.id, tabType: .query) + + let state = SessionStateFactory.create(connection: conn, payload: payload) + + #expect(state.tabManager.tabs.isEmpty) + } + + @Test("Factory is idempotent: two calls produce fresh but equivalent instances") + @MainActor + func factoryIsIdempotent() { + let conn = TestFixtures.makeConnection() + let payload = makePayload( + connectionId: conn.id, + tabType: .table, + tableName: "products" + ) + + let state1 = SessionStateFactory.create(connection: conn, payload: payload) + let state2 = SessionStateFactory.create(connection: conn, payload: payload) + + // Different instances + #expect(state1.tabManager !== state2.tabManager) + #expect(state1.coordinator !== state2.coordinator) + + // Equivalent content + #expect(state1.tabManager.tabs.count == state2.tabManager.tabs.count) + #expect(state1.tabManager.tabs.first?.tableName == state2.tabManager.tabs.first?.tableName) + } + + @Test("Coordinator receives the factory's tabManager") + @MainActor + func coordinatorReceivesCorrectDependencies() { + let conn = TestFixtures.makeConnection() + let payload = makePayload( + connectionId: conn.id, + tabType: .table, + tableName: "items" + ) + + let state = SessionStateFactory.create(connection: conn, payload: payload) + + #expect(state.coordinator.tabManager === state.tabManager) + } +} diff --git a/docs/databases/connection-urls.mdx b/docs/databases/connection-urls.mdx index 565bd949..42cc9aa0 100644 --- a/docs/databases/connection-urls.mdx +++ b/docs/databases/connection-urls.mdx @@ -23,6 +23,8 @@ TablePro parses standard database connection URLs for importing connections, ope | `redshift://` | Amazon Redshift | | `mssql://` | Microsoft SQL Server | | `sqlserver://` | Microsoft SQL Server (alias) | +| `oracle://` | Oracle Database | +| `jdbc:oracle:thin:@//` | Oracle Database (JDBC thin) | Append `+ssh` to any scheme (except SQLite) to use an SSH tunnel: diff --git a/docs/databases/oracle.mdx b/docs/databases/oracle.mdx new file mode 100644 index 00000000..8af4bd47 --- /dev/null +++ b/docs/databases/oracle.mdx @@ -0,0 +1,295 @@ +--- +title: Oracle Database +description: Connect to Oracle Database with TablePro +--- + +# Oracle Database Connections + +TablePro supports Oracle Database 12c and later via Oracle Call Interface (OCI). This covers Oracle Database instances running on-premises, in Docker, or Oracle Cloud. + + +Oracle Instant Client must be installed before connecting to Oracle Database. Download it from [Oracle's website](https://www.oracle.com/database/technologies/instant-client.html) and ensure the libraries are accessible. + + +## Quick Setup + + + + Download and install Oracle Instant Client Basic package for macOS + + + Click **New Connection** from the Welcome screen or **File** > **New Connection** + + + Choose **Oracle** from the database type selector + + + Fill in host, port, username, password, and service name + + + Click **Test Connection**, then **Create** + + + +## Connection Settings + +### Required Fields + +| Field | Description | Default | +|-------|-------------|---------| +| **Name** | Connection identifier | - | +| **Host** | Server hostname or IP address | `localhost` | +| **Port** | Oracle listener port | `1521` | +| **Username** | Oracle username | - | +| **Password** | User password | - | +| **Service Name** | Oracle service name (e.g., `ORCL`, `XEPDB1`) | - | + + +TablePro connects using Oracle service names, not SIDs. If you have a SID, check your `tnsnames.ora` for the corresponding service name, or use the SID as the service name (works in most configurations). + + +## Example Configurations + +### Local Development (Oracle XE) + +``` +Name: Local Oracle XE +Host: localhost +Port: 1521 +Username: system +Password: (your password) +Service Name: XEPDB1 +``` + +### Docker Oracle Container + +Start an Oracle XE 21c container for local testing: + +```bash +docker run \ + -e ORACLE_PASSWORD=YourStrong@Passw0rd \ + -p 1521:1521 \ + --name oracle-xe \ + -d gvenzl/oracle-xe:21-slim +``` + +Then connect with: + +``` +Name: Docker Oracle XE +Host: localhost +Port: 1521 +Username: system +Password: YourStrong@Passw0rd +Service Name: XEPDB1 +``` + + +The `gvenzl/oracle-xe` Docker image is a community-maintained lightweight Oracle XE image, ideal for local development and testing. + + +### Remote Server + +``` +Name: Production Oracle +Host: oracle.example.com +Port: 1521 +Username: app_user +Password: (secure password) +Service Name: PRODDB +``` + +### Oracle Cloud (Autonomous Database) + +``` +Name: Oracle Cloud ADB +Host: adb.region.oraclecloud.com +Port: 1522 +Username: ADMIN +Password: (your password) +Service Name: mydb_tp +``` + + +Oracle Autonomous Database uses port 1522 by default and requires TLS. Download the wallet from the Oracle Cloud Console for mTLS connections. + + +## Features + +### Schema Selection + +Oracle organizes objects into schemas, where each schema corresponds to a database user. TablePro lists all accessible schemas and their objects. + +Switch schemas using the database switcher (**⌘K**), which shows available schemas for the current connection. + +### Table Browsing + +For each table, TablePro shows: + +- **Structure**: Columns with Oracle data types, nullability, and default values +- **Indexes**: B-tree and bitmap indexes +- **Foreign Keys**: Relationships to other tables +- **DDL**: The table definition via DBMS_METADATA + +### Query Editor + +Write and execute SQL and PL/SQL in the editor. Pagination uses Oracle 12c row limiting: + +```sql +-- Paginated results +SELECT * +FROM hr.employees +ORDER BY employee_id +OFFSET 0 ROWS FETCH NEXT 50 ROWS ONLY; + +-- Approximate row count (fast, uses statistics) +SELECT num_rows +FROM all_tables +WHERE owner = 'HR' AND table_name = 'EMPLOYEES'; + +-- View definition +SELECT text +FROM all_views +WHERE owner = 'HR' AND view_name = 'EMP_DETAILS'; + +-- List all tables in the current schema +SELECT table_name +FROM user_tables +ORDER BY table_name; +``` + +### Schema Editing + +Modify table structure via ALTER TABLE statements. Oracle uses double-quote notation for case-sensitive identifiers: + +```sql +-- Add a column +ALTER TABLE "HR"."EMPLOYEES" ADD ("LAST_LOGIN" TIMESTAMP); + +-- Rename a column +ALTER TABLE "HR"."EMPLOYEES" RENAME COLUMN "OLD_NAME" TO "NEW_NAME"; + +-- Modify a column +ALTER TABLE "HR"."EMPLOYEES" MODIFY ("SALARY" NUMBER(12,2)); + +-- Drop a column +ALTER TABLE "HR"."EMPLOYEES" DROP COLUMN "LEGACY_FIELD"; + +-- Add an index +CREATE INDEX "IX_EMP_EMAIL" ON "HR"."EMPLOYEES" ("EMAIL"); +``` + +### Foreign Keys + +TablePro displays foreign key relationships in the table structure view. To inspect foreign keys manually: + +```sql +SELECT + c.constraint_name, + cc.column_name, + r.table_name AS referenced_table, + rc.column_name AS referenced_column +FROM all_constraints c +JOIN all_cons_columns cc + ON c.constraint_name = cc.constraint_name AND c.owner = cc.owner +JOIN all_constraints r + ON c.r_constraint_name = r.constraint_name AND c.r_owner = r.owner +JOIN all_cons_columns rc + ON r.constraint_name = rc.constraint_name AND r.owner = rc.owner +WHERE c.constraint_type = 'R' + AND c.owner = 'HR' +ORDER BY c.constraint_name; +``` + +## Authentication + +TablePro uses Oracle database authentication (username and password). OS authentication and wallet-based authentication are not supported. + +To create an Oracle user: + +```sql +-- Create a user (schema) +CREATE USER app_user IDENTIFIED BY "StrongPassword1!"; + +-- Grant basic privileges +GRANT CREATE SESSION TO app_user; +GRANT SELECT ANY TABLE TO app_user; +GRANT INSERT ANY TABLE TO app_user; +GRANT UPDATE ANY TABLE TO app_user; +GRANT DELETE ANY TABLE TO app_user; +``` + +## Troubleshooting + +### Connection Refused + +**Symptoms**: "Unable to connect" or timeout + +**Causes and Solutions**: + +1. **Oracle listener not running** + + Check and start the listener: + ```bash + lsnrctl status + lsnrctl start + ``` + +2. **Port 1521 blocked by firewall** + - Check firewall rules for TCP port 1521 + - For cloud VMs, verify security group settings + +3. **Docker container not started** + ```bash + docker start oracle-xe + docker logs oracle-xe + ``` + +### Invalid Service Name + +**Symptoms**: "ORA-12514: TNS:listener does not currently know of service requested" + +**Solutions**: + +1. Verify the service name: + ```sql + SELECT value FROM v$parameter WHERE name = 'service_names'; + ``` +2. Check registered services on the listener: + ```bash + lsnrctl services + ``` + +### Oracle Instant Client Not Found + +**Symptoms**: "Oracle Instant Client not found" or library loading error + +**Solution**: Install Oracle Instant Client: + +1. Download Basic package from [Oracle Instant Client](https://www.oracle.com/database/technologies/instant-client.html) +2. Extract to a directory (e.g., `/usr/local/oracle/instantclient`) +3. Set `DYLD_LIBRARY_PATH` or copy libraries to a system path + +## Known Limitations + +- **OS authentication not supported.** Only username/password authentication works. +- **Wallet-based authentication** (mTLS for Oracle Cloud) is not yet supported. +- **LONG and LONG RAW columns** (deprecated) may show limited editing support. Use CLOB and BLOB in new schemas. +- **PL/SQL execution** is limited to single anonymous blocks. Package/procedure creation should use the query editor. + +## Next Steps + + + + Connect securely to remote Oracle instances + + + Master the SQL editor features + + + Edit rows and save changes with SQL preview + + + View and modify table structures + + diff --git a/docs/databases/overview.mdx b/docs/databases/overview.mdx index b9dc1f9e..e25827b1 100644 --- a/docs/databases/overview.mdx +++ b/docs/databases/overview.mdx @@ -1,15 +1,15 @@ --- title: Connection Management -description: Create, organize, and manage database connections across 8 supported engines in TablePro +description: Create, organize, and manage database connections across 9 supported engines in TablePro --- # Connection Management -TablePro connects to eight database systems from a single interface. Create connections, organize them with colors, tags, and groups, and switch between them without leaving your workflow. +TablePro connects to nine database systems from a single interface. Create connections, organize them with colors, tags, and groups, and switch between them without leaving your workflow. ## Supported Databases -TablePro supports eight database systems: +TablePro supports nine database systems natively: @@ -36,6 +36,9 @@ TablePro supports eight database systems: SQL Server 2017+ via FreeTDS. Default port: 1433 + + Oracle 12c+ via Oracle Call Interface. Default port: 1521 + ## Creating a Connection @@ -99,7 +102,7 @@ open "mysql://root:secret@localhost:3306/myapp" open "redis://:password@localhost:6379" ``` -TablePro registers `postgresql`, `postgres`, `mysql`, `mariadb`, `sqlite`, `mongodb`, `redis`, `rediss`, and `redshift` as URL schemes on macOS, so the OS routes these URLs directly to the app. +TablePro registers `postgresql`, `postgres`, `mysql`, `mariadb`, `sqlite`, `mongodb`, `mongodb+srv`, `redis`, `rediss`, `redshift`, `mssql`, `sqlserver`, and `oracle` as URL schemes on macOS, so the OS routes these URLs directly to the app. **What happens:** @@ -122,7 +125,7 @@ This is different from **Import from URL**, which fills in the connection form s | Field | Description | |-------|-------------| | **Name** | A friendly name to identify this connection | -| **Type** | Database type: MySQL, MariaDB, PostgreSQL, or SQLite | +| **Type** | Database type: MySQL, MariaDB, PostgreSQL, SQLite, MongoDB, Redis, Redshift, SQL Server, or Oracle | #### Appearance Section @@ -535,6 +538,7 @@ TablePro auto-fills the port when you select a database type: | MongoDB | 27017 | | Redis | 6379 | | Microsoft SQL Server | 1433 | +| Oracle Database | 1521 | ## Related Guides @@ -557,6 +561,9 @@ TablePro auto-fills the port when you select a database type: SQL Server connections via FreeTDS + + Oracle Database connections via OCI + Redshift data warehouse connections diff --git a/docs/docs.json b/docs/docs.json index be3d1b62..42a85474 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -40,6 +40,7 @@ "databases/redis", "databases/redshift", "databases/mssql", + "databases/oracle", "databases/ssh-tunneling" ] }, @@ -128,6 +129,7 @@ "vi/databases/redis", "vi/databases/redshift", "vi/databases/mssql", + "vi/databases/oracle", "vi/databases/ssh-tunneling" ] }, diff --git a/docs/vi/databases/connection-urls.mdx b/docs/vi/databases/connection-urls.mdx index 7242a024..bd17b287 100644 --- a/docs/vi/databases/connection-urls.mdx +++ b/docs/vi/databases/connection-urls.mdx @@ -23,6 +23,8 @@ TablePro phân tích URL kết nối database chuẩn để import kết nối, | `redshift://` | Amazon Redshift | | `mssql://` | Microsoft SQL Server | | `sqlserver://` | Microsoft SQL Server (alias) | +| `oracle://` | Oracle Database | +| `jdbc:oracle:thin:@//` | Oracle Database (JDBC thin) | Thêm `+ssh` vào bất kỳ scheme nào (trừ SQLite) để dùng SSH tunnel: diff --git a/docs/vi/databases/oracle.mdx b/docs/vi/databases/oracle.mdx new file mode 100644 index 00000000..be89731e --- /dev/null +++ b/docs/vi/databases/oracle.mdx @@ -0,0 +1,295 @@ +--- +title: Oracle Database +description: Kết nối đến Oracle Database với TablePro +--- + +# Kết nối Oracle Database + +TablePro hỗ trợ Oracle Database 12c và các phiên bản mới hơn thông qua Oracle Call Interface (OCI). Điều này bao gồm các instance Oracle Database chạy on-premises, trong Docker hoặc Oracle Cloud. + + +Oracle Instant Client phải được cài đặt trước khi kết nối với Oracle Database. Tải về từ [trang web Oracle](https://www.oracle.com/database/technologies/instant-client.html) và đảm bảo các thư viện có thể truy cập được. + + +## Thiết lập nhanh + + + + Tải về và cài đặt gói Oracle Instant Client Basic cho macOS + + + Click **New Connection** từ màn hình Welcome hoặc **File** > **New Connection** + + + Chọn **Oracle** từ danh sách loại cơ sở dữ liệu + + + Điền host, port, username, password và service name + + + Click **Test Connection**, sau đó **Create** + + + +## Cài đặt kết nối + +### Các trường bắt buộc + +| Trường | Mô tả | Mặc định | +|-------|-------------|---------| +| **Name** | Tên định danh kết nối | - | +| **Host** | Hostname hoặc địa chỉ IP của server | `localhost` | +| **Port** | Cổng Oracle listener | `1521` | +| **Username** | Tên người dùng Oracle | - | +| **Password** | Mật khẩu người dùng | - | +| **Service Name** | Tên service Oracle (ví dụ: `ORCL`, `XEPDB1`) | - | + + +TablePro kết nối sử dụng Oracle service name, không phải SID. Nếu bạn có SID, hãy kiểm tra file `tnsnames.ora` để tìm service name tương ứng, hoặc sử dụng SID làm service name (hoạt động trong hầu hết cấu hình). + + +## Các cấu hình ví dụ + +### Phát triển cục bộ (Oracle XE) + +``` +Name: Local Oracle XE +Host: localhost +Port: 1521 +Username: system +Password: (mật khẩu của bạn) +Service Name: XEPDB1 +``` + +### Docker Oracle Container + +Khởi động container Oracle XE 21c để kiểm tra cục bộ: + +```bash +docker run \ + -e ORACLE_PASSWORD=YourStrong@Passw0rd \ + -p 1521:1521 \ + --name oracle-xe \ + -d gvenzl/oracle-xe:21-slim +``` + +Sau đó kết nối với: + +``` +Name: Docker Oracle XE +Host: localhost +Port: 1521 +Username: system +Password: YourStrong@Passw0rd +Service Name: XEPDB1 +``` + + +`gvenzl/oracle-xe` là Docker image Oracle XE nhẹ do cộng đồng duy trì, lý tưởng cho phát triển và kiểm tra cục bộ. + + +### Remote Server + +``` +Name: Production Oracle +Host: oracle.example.com +Port: 1521 +Username: app_user +Password: (mật khẩu an toàn) +Service Name: PRODDB +``` + +### Oracle Cloud (Autonomous Database) + +``` +Name: Oracle Cloud ADB +Host: adb.region.oraclecloud.com +Port: 1522 +Username: ADMIN +Password: (mật khẩu của bạn) +Service Name: mydb_tp +``` + + +Oracle Autonomous Database sử dụng cổng 1522 theo mặc định và yêu cầu TLS. Tải wallet từ Oracle Cloud Console cho kết nối mTLS. + + +## Tính năng + +### Chọn Schema + +Oracle tổ chức các đối tượng thành schemas, trong đó mỗi schema tương ứng với một người dùng database. TablePro liệt kê tất cả schemas có thể truy cập và các đối tượng của chúng. + +Chuyển đổi schema bằng trình chuyển đổi database (**⌘K**), hiển thị các schema có sẵn cho kết nối hiện tại. + +### Duyệt bảng + +Cho mỗi bảng, TablePro hiển thị: + +- **Structure**: Các cột với kiểu dữ liệu Oracle, nullable và giá trị mặc định +- **Indexes**: B-tree và bitmap indexes +- **Foreign Keys**: Quan hệ với các bảng khác +- **DDL**: Định nghĩa bảng thông qua DBMS_METADATA + +### Trình soạn thảo truy vấn + +Viết và thực thi SQL và PL/SQL trong editor. Phân trang sử dụng cú pháp row limiting của Oracle 12c: + +```sql +-- Kết quả phân trang +SELECT * +FROM hr.employees +ORDER BY employee_id +OFFSET 0 ROWS FETCH NEXT 50 ROWS ONLY; + +-- Ước tính số dòng (nhanh, sử dụng thống kê) +SELECT num_rows +FROM all_tables +WHERE owner = 'HR' AND table_name = 'EMPLOYEES'; + +-- Định nghĩa view +SELECT text +FROM all_views +WHERE owner = 'HR' AND view_name = 'EMP_DETAILS'; + +-- Liệt kê tất cả bảng trong schema hiện tại +SELECT table_name +FROM user_tables +ORDER BY table_name; +``` + +### Chỉnh sửa Schema + +Sửa đổi cấu trúc bảng thông qua câu lệnh ALTER TABLE. Oracle sử dụng ký hiệu ngoặc kép cho identifiers phân biệt hoa thường: + +```sql +-- Thêm cột +ALTER TABLE "HR"."EMPLOYEES" ADD ("LAST_LOGIN" TIMESTAMP); + +-- Đổi tên cột +ALTER TABLE "HR"."EMPLOYEES" RENAME COLUMN "OLD_NAME" TO "NEW_NAME"; + +-- Sửa đổi cột +ALTER TABLE "HR"."EMPLOYEES" MODIFY ("SALARY" NUMBER(12,2)); + +-- Xóa cột +ALTER TABLE "HR"."EMPLOYEES" DROP COLUMN "LEGACY_FIELD"; + +-- Thêm index +CREATE INDEX "IX_EMP_EMAIL" ON "HR"."EMPLOYEES" ("EMAIL"); +``` + +### Foreign Keys + +TablePro hiển thị quan hệ foreign key trong view cấu trúc bảng. Để xem foreign keys thủ công: + +```sql +SELECT + c.constraint_name, + cc.column_name, + r.table_name AS referenced_table, + rc.column_name AS referenced_column +FROM all_constraints c +JOIN all_cons_columns cc + ON c.constraint_name = cc.constraint_name AND c.owner = cc.owner +JOIN all_constraints r + ON c.r_constraint_name = r.constraint_name AND c.r_owner = r.owner +JOIN all_cons_columns rc + ON r.constraint_name = rc.constraint_name AND r.owner = rc.owner +WHERE c.constraint_type = 'R' + AND c.owner = 'HR' +ORDER BY c.constraint_name; +``` + +## Xác thực + +TablePro sử dụng xác thực Oracle database (username và password). Xác thực OS và xác thực dựa trên wallet không được hỗ trợ. + +Để tạo người dùng Oracle: + +```sql +-- Tạo người dùng (schema) +CREATE USER app_user IDENTIFIED BY "StrongPassword1!"; + +-- Cấp quyền cơ bản +GRANT CREATE SESSION TO app_user; +GRANT SELECT ANY TABLE TO app_user; +GRANT INSERT ANY TABLE TO app_user; +GRANT UPDATE ANY TABLE TO app_user; +GRANT DELETE ANY TABLE TO app_user; +``` + +## Khắc phục sự cố + +### Connection Refused + +**Triệu chứng**: "Unable to connect" hoặc timeout + +**Nguyên nhân và Giải pháp**: + +1. **Oracle listener không chạy** + + Kiểm tra và khởi động listener: + ```bash + lsnrctl status + lsnrctl start + ``` + +2. **Cổng 1521 bị firewall chặn** + - Kiểm tra firewall rules cho TCP cổng 1521 + - Với cloud VM, kiểm tra security group settings + +3. **Docker container chưa khởi động** + ```bash + docker start oracle-xe + docker logs oracle-xe + ``` + +### Service Name không hợp lệ + +**Triệu chứng**: "ORA-12514: TNS:listener does not currently know of service requested" + +**Giải pháp**: + +1. Xác minh service name: + ```sql + SELECT value FROM v$parameter WHERE name = 'service_names'; + ``` +2. Kiểm tra các service đã đăng ký trên listener: + ```bash + lsnrctl services + ``` + +### Oracle Instant Client không tìm thấy + +**Triệu chứng**: "Oracle Instant Client not found" hoặc lỗi tải thư viện + +**Giải pháp**: Cài đặt Oracle Instant Client: + +1. Tải gói Basic từ [Oracle Instant Client](https://www.oracle.com/database/technologies/instant-client.html) +2. Giải nén vào một thư mục (ví dụ: `/usr/local/oracle/instantclient`) +3. Đặt `DYLD_LIBRARY_PATH` hoặc sao chép thư viện vào system path + +## Hạn chế đã biết + +- **Xác thực OS không được hỗ trợ.** Chỉ xác thực username/password hoạt động. +- **Xác thực dựa trên wallet** (mTLS cho Oracle Cloud) chưa được hỗ trợ. +- **Cột LONG và LONG RAW** (đã deprecated) có thể có hỗ trợ chỉnh sửa hạn chế. Sử dụng CLOB và BLOB trong schema mới. +- **Thực thi PL/SQL** giới hạn ở single anonymous blocks. Tạo package/procedure nên sử dụng query editor. + +## Bước tiếp theo + + + + Kết nối an toàn đến các instance Oracle remote + + + Làm chủ các tính năng SQL editor + + + Chỉnh sửa hàng và lưu thay đổi với xem trước SQL + + + Xem và sửa đổi cấu trúc bảng + + diff --git a/docs/vi/databases/overview.mdx b/docs/vi/databases/overview.mdx index 76b97c78..44b5c5ce 100644 --- a/docs/vi/databases/overview.mdx +++ b/docs/vi/databases/overview.mdx @@ -1,15 +1,15 @@ --- title: Quản lý Kết nối -description: Tạo, tổ chức và quản lý kết nối đến 8 hệ quản trị cơ sở dữ liệu từ một giao diện duy nhất +description: Tạo, tổ chức và quản lý kết nối đến 9 hệ quản trị cơ sở dữ liệu từ một giao diện duy nhất --- # Quản lý Kết nối -TablePro kết nối được đến 8 hệ quản trị cơ sở dữ liệu từ cùng một giao diện. Tạo kết nối, sắp xếp bằng màu sắc, tag và nhóm, rồi chuyển đổi giữa chúng mà không cần rời khỏi cửa sổ làm việc. +TablePro kết nối được đến 9 hệ quản trị cơ sở dữ liệu từ cùng một giao diện. Tạo kết nối, sắp xếp bằng màu sắc, tag và nhóm, rồi chuyển đổi giữa chúng mà không cần rời khỏi cửa sổ làm việc. ## Cơ sở dữ liệu được hỗ trợ -TablePro hỗ trợ 8 hệ thống cơ sở dữ liệu: +TablePro hỗ trợ 9 hệ thống cơ sở dữ liệu: @@ -36,6 +36,9 @@ TablePro hỗ trợ 8 hệ thống cơ sở dữ liệu: SQL Server 2017+ qua FreeTDS. Cổng mặc định: 1433 + + Oracle 12c+ qua Oracle Call Interface. Cổng mặc định: 1521 + ## Tạo Kết nối @@ -99,7 +102,7 @@ open "mysql://root:secret@localhost:3306/myapp" open "redis://:password@localhost:6379" ``` -TablePro đăng ký `postgresql`, `postgres`, `mysql`, `mariadb`, `sqlite`, `mongodb`, `redis`, `rediss` và `redshift` là URL scheme trên macOS, nên hệ điều hành chuyển hướng các URL này trực tiếp đến ứng dụng. +TablePro đăng ký `postgresql`, `postgres`, `mysql`, `mariadb`, `sqlite`, `mongodb`, `mongodb+srv`, `redis`, `rediss`, `redshift`, `mssql`, `sqlserver` và `oracle` là các URL scheme trên macOS, nên hệ điều hành sẽ chuyển hướng các URL này trực tiếp đến ứng dụng. **Khi mở URL:** @@ -121,8 +124,8 @@ Khác với **Import from URL** (điền form để bạn xem xét và lưu), m | Trường | Mô tả | |-------|-------------| -| **Name** | Tên để nhận diện kết nối | -| **Type** | Loại cơ sở dữ liệu: MySQL, MariaDB, PostgreSQL hoặc SQLite | +| **Name** | Tên thân thiện để xác định kết nối này | +| **Type** | Loại cơ sở dữ liệu: MySQL, MariaDB, PostgreSQL, SQLite, MongoDB, Redis, Redshift, SQL Server hoặc Oracle | #### Phần Appearance @@ -535,6 +538,7 @@ TablePro tự động điền cổng khi chọn loại database: | MongoDB | 27017 | | Redis | 6379 | | Microsoft SQL Server | 1433 | +| Oracle Database | 1521 | ## Hướng dẫn Liên quan @@ -557,6 +561,9 @@ TablePro tự động điền cổng khi chọn loại database: Kết nối SQL Server qua FreeTDS + + Kết nối Oracle Database qua OCI + Kết nối kho dữ liệu Redshift