From 90c747baeb79a2ff682efee974b95fd7335964dd Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 5 Mar 2026 23:44:56 +0700 Subject: [PATCH 01/10] fix: prevent session state recreation on every tab open via SwiftUI @State init trap --- CHANGELOG.md | 4 + TablePro/ContentView.swift | 51 +++++- .../Core/Services/SessionStateFactory.swift | 92 ++++++++++ TablePro/Models/MultiRowEditState.swift | 10 + TablePro/Models/RightPanelState.swift | 9 + TablePro/ViewModels/AIChatViewModel.swift | 22 +++ .../Views/Main/MainContentCoordinator.swift | 2 + TablePro/Views/MainContentView.swift | 104 +++-------- .../Models/RightPanelStateTests.swift | 24 +++ .../Views/Main/SessionStateFactoryTests.swift | 173 ++++++++++++++++++ 10 files changed, 411 insertions(+), 80 deletions(-) create mode 100644 TablePro/Core/Services/SessionStateFactory.swift create mode 100644 TableProTests/Views/Main/SessionStateFactoryTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 87c0f7f4..c6b82b05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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 + ## [0.14.0] - 2026-03-05 ### Added diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index 59000bb3..71d1e6db 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 /// Per-window sidebar selection (independent of other window-tabs) @@ -110,6 +111,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 @@ -132,12 +142,32 @@ struct ContentView: View { guard let newSession = sessions[sid] else { // Session was removed (disconnected) 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 } @@ -146,6 +176,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 @@ -172,7 +211,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) { @@ -204,7 +243,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( @@ -216,7 +260,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/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/Models/MultiRowEditState.swift b/TablePro/Models/MultiRowEditState.swift index db3e953c..d9d49b33 100644 --- a/TablePro/Models/MultiRowEditState.swift +++ b/TablePro/Models/MultiRowEditState.swift @@ -209,6 +209,16 @@ 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/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/ViewModels/AIChatViewModel.swift b/TablePro/ViewModels/AIChatViewModel.swift index 7578e143..6ac22651 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/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 075249d4..09a36fcd 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -191,6 +191,8 @@ final class MainContentCoordinator { for tab in tabManager.tabs { tab.rowBuffer.evict() } + tabManager.tabs.removeAll() + tabManager.selectedTabId = nil querySortCache.removeAll() Self.releaseSchemaProvider(for: connection.id) diff --git a/TablePro/Views/MainContentView.swift b/TablePro/Views/MainContentView.swift index 0d0e2d73..8bfc73f1 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 @@ -64,7 +64,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 @@ -76,81 +81,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)) @@ -272,6 +214,7 @@ struct MainContentView: View { // Window truly closed — teardown coordinator coordinator.teardown() + rightPanelState.teardown() // If no more windows for this connection, disconnect guard !NativeTabRegistry.shared.hasWindows(for: connectionId) else { return } @@ -984,6 +927,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, @@ -994,7 +941,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/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) + } +} From 9627352d3b095e0582c4b3a93d8eb573ff184625 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 6 Mar 2026 01:16:32 +0700 Subject: [PATCH 02/10] feat: add Oracle Database support --- CHANGELOG.md | 10 + TablePro/AppDelegate.swift | 18 +- .../oracle-icon.imageset/Contents.json | 16 + .../oracle-icon.imageset/oracle.svg | 1 + .../Autocomplete/SQLCompletionProvider.swift | 14 +- .../SQLStatementGenerator.swift | 14 +- TablePro/Core/Database/COracle/COracle.h | 9 + .../Core/Database/COracle/include/oci_stub.h | 199 +++++++ .../Core/Database/COracle/module.modulemap | 4 + TablePro/Core/Database/DatabaseDriver.swift | 12 +- TablePro/Core/Database/DatabaseManager.swift | 65 ++- .../Core/Database/FilterSQLGenerator.swift | 10 +- TablePro/Core/Database/OracleConnection.swift | 453 ++++++++++++++++ TablePro/Core/Database/OracleDriver.swift | 485 ++++++++++++++++++ TablePro/Core/Database/SQLEscaping.swift | 2 +- .../SchemaStatementGenerator.swift | 60 ++- TablePro/Core/Services/ImportService.swift | 9 +- .../Core/Services/SQLDialectProvider.swift | 66 ++- .../Core/Services/TableQueryBuilder.swift | 62 ++- .../Utilities/ConnectionURLFormatter.swift | 2 + .../Core/Utilities/ConnectionURLParser.swift | 4 + .../Core/Utilities/SQLParameterInliner.swift | 4 +- TablePro/Models/DatabaseConnection.swift | 21 +- TablePro/Models/QueryTab.swift | 6 + TablePro/Theme/Theme.swift | 6 + .../DatabaseSwitcherViewModel.swift | 6 +- .../Views/Connection/ConnectionFormView.swift | 24 +- .../DatabaseSwitcherSheet.swift | 2 +- TablePro/Views/Export/ExportDialog.swift | 24 +- .../MainContentCoordinator+Discard.swift | 2 + .../MainContentCoordinator+Navigation.swift | 67 ++- .../MainContentCoordinator+SQLPreview.swift | 7 +- ...inContentCoordinator+TableOperations.swift | 23 +- .../Main/MainContentCommandActions.swift | 11 +- .../Views/Main/MainContentCoordinator.swift | 13 +- .../Views/Sidebar/TableOperationDialog.swift | 6 +- .../Structure/TypePickerContentView.swift | 20 +- 37 files changed, 1663 insertions(+), 94 deletions(-) create mode 100644 TablePro/Assets.xcassets/oracle-icon.imageset/Contents.json create mode 100644 TablePro/Assets.xcassets/oracle-icon.imageset/oracle.svg create mode 100644 TablePro/Core/Database/COracle/COracle.h create mode 100644 TablePro/Core/Database/COracle/include/oci_stub.h create mode 100644 TablePro/Core/Database/COracle/module.modulemap create mode 100644 TablePro/Core/Database/OracleConnection.swift create mode 100644 TablePro/Core/Database/OracleDriver.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index c6b82b05..e0d863f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Oracle Database support via OCI (Oracle Call Interface) +- CockroachDB database support (PostgreSQL wire-compatible via LibPQ) + ### 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 ## [0.14.0] - 2026-03-05 diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index 110cbdc7..266b6645 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -37,7 +37,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { private static let databaseURLSchemes: Set = [ "postgresql", "postgres", "mysql", "mariadb", "sqlite", - "mongodb", "redis", "rediss", "redshift" + "mongodb", "redis", "rediss", "redshift", "cockroachdb" ] func applicationDockMenu(_ sender: NSApplication) -> NSMenu? { @@ -391,16 +391,18 @@ class AppDelegate: NSObject, NSApplicationDelegate { @MainActor private func handlePostConnectionActions(_ parsed: ParsedConnectionURL, connectionId: UUID) { Task { @MainActor in + // Allow SwiftUI to finish processing the new connection before acting on it try? await Task.sleep(for: .milliseconds(300)) // Switch schema if specified (PostgreSQL/Redshift only) if let schema = parsed.schema, - parsed.type == .postgresql || parsed.type == .redshift { + parsed.type == .postgresql || parsed.type == .redshift || parsed.type == .cockroachdb { NotificationCenter.default.post( name: .switchSchemaFromURL, 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)) } @@ -416,7 +418,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Apply filter after table loads 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, @@ -503,10 +506,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() @@ -536,6 +539,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") @@ -768,7 +772,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/Core/Autocomplete/SQLCompletionProvider.swift b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift index 24d801a0..51dd5c07 100644 --- a/TablePro/Core/Autocomplete/SQLCompletionProvider.swift +++ b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift @@ -327,7 +327,7 @@ final class SQLCompletionProvider { "ENGINE", "CHARSET", "COLLATE", "COMMENT", "AUTO_INCREMENT", "ROW_FORMAT", "DEFAULT CHARSET", ]) - case .postgresql, .redshift: + case .postgresql, .redshift, .cockroachdb: items += filterKeywords([ "TABLESPACE", "INHERITS", "PARTITION BY", "WITH", "WITHOUT OIDS", @@ -491,7 +491,7 @@ final class SQLCompletionProvider { "BINARY", "VARBINARY", ] - case .postgresql, .redshift: + case .postgresql, .redshift, .cockroachdb: types += [ "BIGSERIAL", "SERIAL", "SMALLSERIAL", "DOUBLE PRECISION", "MONEY", @@ -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 f79d613b..da24f2a1 100644 --- a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift +++ b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift @@ -120,10 +120,10 @@ struct SQLStatementGenerator { /// Get placeholder syntax for the database type private func placeholder(at index: Int) -> String { switch databaseType { - case .postgresql, .redshift: + case .postgresql, .redshift, .cockroachdb: 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 ? } } @@ -297,7 +297,9 @@ 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 .postgresql, .redshift, .mongodb, .redis: + case .oracle: + sql = "UPDATE \(databaseType.quoteIdentifier(tableName)) SET \(setClauses) WHERE \(whereClause) AND ROWNUM = 1" + case .postgresql, .redshift, .cockroachdb, .mongodb, .redis: sql = "UPDATE \(databaseType.quoteIdentifier(tableName)) SET \(setClauses) WHERE \(whereClause)" } @@ -371,7 +373,9 @@ 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 .postgresql, .redshift, .mongodb, .redis: + case .oracle: + sql = "DELETE FROM \(databaseType.quoteIdentifier(tableName)) WHERE \(whereClause) AND ROWNUM = 1" + case .postgresql, .redshift, .cockroachdb, .mongodb, .redis: sql = "DELETE FROM \(databaseType.quoteIdentifier(tableName)) WHERE \(whereClause)" } diff --git a/TablePro/Core/Database/COracle/COracle.h b/TablePro/Core/Database/COracle/COracle.h new file mode 100644 index 00000000..f660206f --- /dev/null +++ b/TablePro/Core/Database/COracle/COracle.h @@ -0,0 +1,9 @@ +// +// COracle.h +// TablePro +// +// Umbrella header for Oracle OCI C bridge. +// Requires Oracle Instant Client headers in include/. +// + +#include "include/oci_stub.h" diff --git a/TablePro/Core/Database/COracle/include/oci_stub.h b/TablePro/Core/Database/COracle/include/oci_stub.h new file mode 100644 index 00000000..847f7274 --- /dev/null +++ b/TablePro/Core/Database/COracle/include/oci_stub.h @@ -0,0 +1,199 @@ +// +// oci_stub.h - Oracle OCI stub header +// Swift-compatible bridge: real Oracle Instant Client provides the implementation. +// +#ifndef _OCI_STUB_H_ +#define _OCI_STUB_H_ + +#include + +// Basic OCI types +typedef int32_t sword; +typedef uint32_t ub4; +typedef uint16_t ub2; +typedef uint8_t ub1; +typedef int32_t sb4; +typedef int16_t sb2; +typedef int8_t sb1; +typedef char OraText; +typedef unsigned char oraub8_t; +typedef int64_t orasb8_t; + +// OCI Return codes +#define OCI_SUCCESS 0 +#define OCI_SUCCESS_WITH_INFO 1 +#define OCI_NO_DATA 100 +#define OCI_ERROR -1 +#define OCI_INVALID_HANDLE -2 +#define OCI_NEED_DATA 99 +#define OCI_STILL_EXECUTING -3123 + +// OCI Handle types +#define OCI_HTYPE_ENV 1 +#define OCI_HTYPE_ERROR 2 +#define OCI_HTYPE_SVCCTX 3 +#define OCI_HTYPE_STMT 4 +#define OCI_HTYPE_SERVER 8 +#define OCI_HTYPE_SESSION 9 +#define OCI_HTYPE_AUTHINFO 12 + +// OCI Descriptor types +#define OCI_DTYPE_PARAM 53 + +// OCI Attribute types +#define OCI_ATTR_SERVER 6 +#define OCI_ATTR_SESSION 7 +#define OCI_ATTR_USERNAME 22 +#define OCI_ATTR_PASSWORD 23 +#define OCI_ATTR_DATA_TYPE 24 +#define OCI_ATTR_DATA_SIZE 25 +#define OCI_ATTR_NAME 26 +#define OCI_ATTR_PRECISION 27 +#define OCI_ATTR_SCALE 28 +#define OCI_ATTR_IS_NULL 29 +#define OCI_ATTR_ROW_COUNT 30 +#define OCI_ATTR_NUM_COLS 31 +#define OCI_ATTR_PARAM_COUNT 32 + +// OCI Data types +#define SQLT_CHR 1 // VARCHAR2 +#define SQLT_NUM 2 // NUMBER +#define SQLT_INT 3 // INTEGER +#define SQLT_FLT 4 // FLOAT +#define SQLT_STR 5 // NULL-terminated STRING +#define SQLT_LNG 8 // LONG +#define SQLT_RID 11 // ROWID +#define SQLT_DAT 12 // DATE +#define SQLT_BIN 23 // RAW +#define SQLT_LBI 24 // LONG RAW +#define SQLT_AFC 96 // CHAR +#define SQLT_AVC 97 // CHARZ +#define SQLT_IBFLOAT 100 // Binary FLOAT (BINARY_FLOAT) +#define SQLT_IBDOUBLE 101 // Binary DOUBLE (BINARY_DOUBLE) +#define SQLT_RDD 104 // ROWID descriptor +#define SQLT_NTY 108 // Named type (Object type, VARRAY, nested table) +#define SQLT_CLOB 112 // CLOB +#define SQLT_BLOB 113 // BLOB +#define SQLT_BFILEE 114 // BFILE +#define SQLT_TIMESTAMP 187 // TIMESTAMP +#define SQLT_TIMESTAMP_TZ 188 // TIMESTAMP WITH TIME ZONE +#define SQLT_INTERVAL_YM 189 // INTERVAL YEAR TO MONTH +#define SQLT_INTERVAL_DS 190 // INTERVAL DAY TO SECOND +#define SQLT_TIMESTAMP_LTZ 232 // TIMESTAMP WITH LOCAL TIME ZONE + +// OCI Credentials +#define OCI_CRED_RDBMS 1 +#define OCI_CRED_EXT 2 + +// OCI Mode flags +#define OCI_DEFAULT 0x00000000 +#define OCI_THREADED 0x00000001 +#define OCI_OBJECT 0x00000002 +#define OCI_COMMIT_ON_SUCCESS 0x00000020 +#define OCI_DESCRIBE_ONLY 0x00000010 +#define OCI_STMT_SCROLLABLE_READONLY 0x00000008 + +// OCI Statement types +#define OCI_STMT_SELECT 1 +#define OCI_STMT_UPDATE 2 +#define OCI_STMT_DELETE 3 +#define OCI_STMT_INSERT 4 +#define OCI_STMT_CREATE 5 +#define OCI_STMT_DROP 6 +#define OCI_STMT_ALTER 7 +#define OCI_STMT_BEGIN 8 +#define OCI_STMT_DECLARE 9 + +// OCI Fetch orientation +#define OCI_FETCH_NEXT 2 + +// Opaque handle types — placeholder bodies for Swift UnsafeMutablePointer compatibility +struct OCIEnv { char _placeholder; }; +typedef struct OCIEnv OCIEnv; + +struct OCIError { char _placeholder; }; +typedef struct OCIError OCIError; + +struct OCISvcCtx { char _placeholder; }; +typedef struct OCISvcCtx OCISvcCtx; + +struct OCIStmt { char _placeholder; }; +typedef struct OCIStmt OCIStmt; + +struct OCIServer { char _placeholder; }; +typedef struct OCIServer OCIServer; + +struct OCISession { char _placeholder; }; +typedef struct OCISession OCISession; + +struct OCIDefine { char _placeholder; }; +typedef struct OCIDefine OCIDefine; + +struct OCIParam { char _placeholder; }; +typedef struct OCIParam OCIParam; + +struct OCIAuthInfo { char _placeholder; }; +typedef struct OCIAuthInfo OCIAuthInfo; + +// --- OCI Function Prototypes --- + +// Environment +sword OCIEnvCreate(OCIEnv **envhpp, ub4 mode, const void *ctxp, + const void *(*malfp)(void *, size_t), + const void *(*ralfp)(void *, void *, size_t), + void (*mfreefp)(void *, void *), + size_t xtramem_sz, void **usrmempp); + +// Handle allocation/free +sword OCIHandleAlloc(const void *parenth, void **hndlpp, ub4 type, + size_t xtramem_sz, void **usrmempp); +sword OCIHandleFree(void *hndlp, ub4 type); + +// Attribute get/set +sword OCIAttrGet(const void *trgthndlp, ub4 trghndltyp, + void *attributep, ub4 *sizep, ub4 attrtype, + OCIError *errhp); +sword OCIAttrSet(void *trgthndlp, ub4 trghndltyp, + void *attributep, ub4 size, ub4 attrtype, + OCIError *errhp); + +// Server attach/detach +sword OCIServerAttach(OCIServer *srvhp, OCIError *errhp, + const OraText *dblink, sb4 dblink_len, ub4 mode); +sword OCIServerDetach(OCIServer *srvhp, OCIError *errhp, ub4 mode); + +// Session begin/end +sword OCISessionBegin(OCISvcCtx *svchp, OCIError *errhp, + OCISession *usrhp, ub4 creession, ub4 mode); +sword OCISessionEnd(OCISvcCtx *svchp, OCIError *errhp, + OCISession *usrhp, ub4 mode); + +// Statement prepare/execute/fetch +sword OCIStmtPrepare(OCIStmt *stmtp, OCIError *errhp, + const OraText *stmt, ub4 stmt_len, + ub4 language, ub4 mode); +sword OCIStmtExecute(OCISvcCtx *svchp, OCIStmt *stmtp, OCIError *errhp, + ub4 iters, ub4 rowoff, const void *snap_in, + void *snap_out, ub4 mode); +sword OCIStmtFetch2(OCIStmt *stmtp, OCIError *errhp, ub4 nrows, + ub2 orientation, sb4 fetchOffset, ub4 mode); + +// Define by position (for SELECT result binding) +sword OCIDefineByPos(OCIStmt *stmtp, OCIDefine **defnpp, OCIError *errhp, + ub4 position, void *valuep, sb4 value_sz, + ub2 dty, void *indp, ub2 *rlenp, ub2 *rcodep, + ub4 mode); + +// Parameter descriptor +sword OCIParamGet(const void *hndlp, ub4 htype, OCIError *errhp, + void **parmdpp, ub4 pos); + +// Transaction +sword OCITransCommit(OCISvcCtx *svchp, OCIError *errhp, ub4 flags); +sword OCITransRollback(OCISvcCtx *svchp, OCIError *errhp, ub4 flags); + +// Error info +sword OCIErrorGet(void *hndlp, ub4 recordno, OraText *sqlstate, + sb4 *errcodep, OraText *bufp, ub4 bufsiz, ub4 type); + +#endif // _OCI_STUB_H_ diff --git a/TablePro/Core/Database/COracle/module.modulemap b/TablePro/Core/Database/COracle/module.modulemap new file mode 100644 index 00000000..3ea4e9cf --- /dev/null +++ b/TablePro/Core/Database/COracle/module.modulemap @@ -0,0 +1,4 @@ +module COracle { + umbrella header "COracle.h" + export * +} diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 13e1e2b5..78bce402 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -265,7 +265,7 @@ extension DatabaseDriver { _ = try await execute(query: "SET SESSION max_execution_time = \(ms)") case .mariadb: _ = try await execute(query: "SET SESSION max_statement_time = \(seconds)") - case .postgresql, .redshift: + case .postgresql, .redshift, .cockroachdb: _ = try await execute(query: "SET statement_timeout = '\(ms)'") case .sqlite: break // SQLite busy_timeout handled by driver directly @@ -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") @@ -290,7 +292,7 @@ extension DatabaseDriver { switch connection.type { case .mysql, .mariadb: sql = "START TRANSACTION" - case .postgresql, .redshift: + case .postgresql, .redshift, .cockroachdb: sql = "BEGIN" case .sqlite: sql = "BEGIN" @@ -300,6 +302,8 @@ extension DatabaseDriver { sql = "" // Redis transactions handled by RedisDriver directly case .mssql: sql = "BEGIN TRANSACTION" + case .oracle: + sql = "" // Oracle auto-starts transactions } guard !sql.isEmpty else { return } _ = try await execute(query: sql) @@ -326,12 +330,16 @@ enum DatabaseDriverFactory { return PostgreSQLDriver(connection: connection) case .redshift: return RedshiftDriver(connection: connection) + case .cockroachdb: + return CockroachDBDriver(connection: connection) case .mongodb: return MongoDBDriver(connection: connection) case .redis: 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 46b6d351..76fe985c 100644 --- a/TablePro/Core/Database/DatabaseManager.swift +++ b/TablePro/Core/Database/DatabaseManager.swift @@ -134,11 +134,13 @@ final class DatabaseManager { try await driver.applyQueryTimeout(timeoutSeconds) } - // Initialize schema for PostgreSQL/Redshift connections + // Initialize schema for PostgreSQL/Redshift/CockroachDB connections if let pgDriver = driver as? PostgreSQLDriver { activeSessions[connection.id]?.currentSchema = pgDriver.currentSchema } else if let rsDriver = driver as? RedshiftDriver { activeSessions[connection.id]?.currentSchema = rsDriver.currentSchema + } else if let crdbDriver = driver as? CockroachDBDriver { + activeSessions[connection.id]?.currentSchema = crdbDriver.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 @@ -198,12 +200,16 @@ final class DatabaseManager { if metaTimeout > 0 { try? await metaDriver.applyQueryTimeout(metaTimeout) } - // Sync schema on metadata driver for PostgreSQL/Redshift + // Sync schema on metadata driver for PostgreSQL/Redshift/CockroachDB if let savedSchema = self.activeSessions[metaConnectionId]?.currentSchema { if let pgMetaDriver = metaDriver as? PostgreSQLDriver { try? await pgMetaDriver.switchSchema(to: savedSchema) } else if let rsMetaDriver = metaDriver as? RedshiftDriver { try? await rsMetaDriver.switchSchema(to: savedSchema) + } else if let crdbMetaDriver = metaDriver as? CockroachDBDriver { + try? await crdbMetaDriver.switchSchema(to: savedSchema) + } else if let oracleMetaDriver = metaDriver as? OracleDriver { + try? await oracleMetaDriver.switchSchema(to: savedSchema) } } activeSessions[metaConnectionId]?.metadataDriver = metaDriver @@ -545,12 +551,16 @@ 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/CockroachDB if session had a non-default schema if let savedSchema = session.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 crdbDriver = driver as? CockroachDBDriver { + try? await crdbDriver.switchSchema(to: savedSchema) + } else if let oracleDriver = driver as? OracleDriver { + try? await oracleDriver.switchSchema(to: savedSchema) } } @@ -619,12 +629,16 @@ 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/CockroachDB/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 crdbDriver = driver as? CockroachDBDriver { + try? await crdbDriver.switchSchema(to: savedSchema) + } else if let oracleDriver = driver as? OracleDriver { + try? await oracleDriver.switchSchema(to: savedSchema) } } @@ -659,6 +673,10 @@ 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 crdbMetaDriver = metaDriver as? CockroachDBDriver { + try? await crdbMetaDriver.switchSchema(to: savedSchema) + } else if let oracleMetaDriver = metaDriver as? OracleDriver { + try? await oracleMetaDriver.switchSchema(to: savedSchema) } } // Restore database on metadata driver too for MSSQL @@ -694,7 +712,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 } @@ -705,22 +723,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.. String? { // Only needed for PostgreSQL PK modifications - guard databaseType == .postgresql || databaseType == .redshift else { return nil } + guard databaseType == .postgresql || databaseType == .redshift || databaseType == .cockroachdb else { return nil } guard changes.contains(where: { if case .modifyPrimaryKey = $0 { return true } @@ -815,6 +840,8 @@ final class DatabaseManager { schema = pgDriver.escapedSchema } else if let rsDriver = driver as? RedshiftDriver { schema = rsDriver.escapedSchema + } else if let crdbDriver = driver as? CockroachDBDriver { + schema = crdbDriver.escapedSchema } else { schema = "public" } diff --git a/TablePro/Core/Database/FilterSQLGenerator.swift b/TablePro/Core/Database/FilterSQLGenerator.swift index 3f092f39..6e8074f5 100644 --- a/TablePro/Core/Database/FilterSQLGenerator.swift +++ b/TablePro/Core/Database/FilterSQLGenerator.swift @@ -117,7 +117,7 @@ struct FilterSQLGenerator { switch databaseType { case .mysql, .mariadb: return "" - case .postgresql, .redshift, .sqlite, .mongodb, .redis, .mssql: + case .postgresql, .redshift, .cockroachdb, .sqlite, .mongodb, .redis, .mssql, .oracle: return " ESCAPE '\\'" } } @@ -140,9 +140,9 @@ struct FilterSQLGenerator { switch databaseType { case .mysql, .mariadb: return "\(column) REGEXP '\(escapedPattern)'" - case .postgresql, .redshift: + case .postgresql, .redshift, .cockroachdb: return "\(column) ~ '\(escapedPattern)'" - case .sqlite, .mongodb, .redis, .mssql: + case .sqlite, .mongodb, .redis, .mssql, .oracle: return "\(column) LIKE '%\(escapedPattern)%'" } } @@ -160,10 +160,10 @@ struct FilterSQLGenerator { // Check for boolean literals if trimmed.caseInsensitiveCompare("TRUE") == .orderedSame { - return databaseType == .postgresql || databaseType == .redshift ? "TRUE" : "1" + return databaseType == .postgresql || databaseType == .redshift || databaseType == .cockroachdb ? "TRUE" : "1" } if trimmed.caseInsensitiveCompare("FALSE") == .orderedSame { - return databaseType == .postgresql || databaseType == .redshift ? "FALSE" : "0" + return databaseType == .postgresql || databaseType == .redshift || databaseType == .cockroachdb ? "FALSE" : "0" } // Try to detect numeric values diff --git a/TablePro/Core/Database/OracleConnection.swift b/TablePro/Core/Database/OracleConnection.swift new file mode 100644 index 00000000..9b8473f5 --- /dev/null +++ b/TablePro/Core/Database/OracleConnection.swift @@ -0,0 +1,453 @@ +// +// OracleConnection.swift +// TablePro +// +// Swift wrapper around Oracle OCI C API. +// Provides thread-safe, async-friendly Oracle Database connections. +// + +import COracle +import Foundation +import OSLog + +private let logger = Logger(subsystem: "com.TablePro", category: "OracleConnection") + +// MARK: - Error Types + +struct OracleError: Error, LocalizedError { + let message: String + + var errorDescription: String? { "Oracle Error: \(message)" } + + static let notConnected = OracleError(message: "Not connected to database") + static let connectionFailed = OracleError(message: "Failed to establish connection") + static let queryFailed = OracleError(message: "Query execution failed") +} + +// MARK: - Query Result + +struct OracleQueryResult { + let columns: [String] + let columnTypeNames: [String] + let rows: [[String?]] + let affectedRows: Int +} + +// MARK: - Connection Class + +final class OracleConnection: @unchecked Sendable { + // MARK: - Properties + + private var envHandle: UnsafeMutablePointer? + private var errHandle: UnsafeMutablePointer? + private var svcHandle: UnsafeMutablePointer? + private var srvHandle: UnsafeMutablePointer? + private var sesHandle: UnsafeMutablePointer? + + private let queue: DispatchQueue + + private let host: String + private let port: Int + private let user: String + private let password: String + private let database: String + + private let lock = NSLock() + private var _isConnected = false + + var isConnected: Bool { + lock.lock() + defer { lock.unlock() } + return _isConnected + } + + // MARK: - Initialization + + init(host: String, port: Int, user: String, password: String, database: String) { + self.queue = DispatchQueue(label: "com.TablePro.oracle.\(host).\(port)", qos: .userInitiated) + self.host = host + self.port = port + self.user = user + self.password = password + self.database = database + } + + // MARK: - Connection + + func connect() async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + queue.async { [self] in + do { + try self.connectSync() + continuation.resume() + } catch { + continuation.resume(throwing: error) + } + } + } + } + + private func connectSync() throws { + // Create OCI environment + var env: UnsafeMutableRawPointer? + var status = OCIEnvCreate( + &envHandle, UInt32(OCI_THREADED), + nil, nil, nil, nil, 0, nil + ) + guard status == Int32(OCI_SUCCESS), envHandle != nil else { + throw OracleError(message: "Failed to create OCI environment") + } + + // Allocate error handle + status = OCIHandleAlloc( + envHandle, &env, UInt32(OCI_HTYPE_ERROR), 0, nil + ) + guard status == Int32(OCI_SUCCESS) else { + throw OracleError(message: "Failed to allocate error handle") + } + errHandle = env?.assumingMemoryBound(to: OCIError.self) + + // Allocate server handle + env = nil + status = OCIHandleAlloc( + envHandle, &env, UInt32(OCI_HTYPE_SERVER), 0, nil + ) + guard status == Int32(OCI_SUCCESS) else { + throw OracleError(message: "Failed to allocate server handle") + } + srvHandle = env?.assumingMemoryBound(to: OCIServer.self) + + // Build connect string: //host:port/service_name + let connectString = "//\(host):\(port)/\(database)" + + // Attach to server + status = connectString.withCString { cStr in + OCIServerAttach( + srvHandle, errHandle, + cStr, Int32(connectString.utf8.count), + UInt32(OCI_DEFAULT) + ) + } + guard status == Int32(OCI_SUCCESS) || status == Int32(OCI_SUCCESS_WITH_INFO) else { + let detail = getErrorMessage() + throw OracleError(message: "Failed to connect to \(host):\(port) \u{2014} \(detail)") + } + + // Allocate service context + env = nil + status = OCIHandleAlloc( + envHandle, &env, UInt32(OCI_HTYPE_SVCCTX), 0, nil + ) + guard status == Int32(OCI_SUCCESS) else { + throw OracleError(message: "Failed to allocate service context") + } + svcHandle = env?.assumingMemoryBound(to: OCISvcCtx.self) + + // Set server on service context + status = OCIAttrSet( + svcHandle, UInt32(OCI_HTYPE_SVCCTX), + srvHandle, 0, UInt32(OCI_ATTR_SERVER), + errHandle + ) + guard status == Int32(OCI_SUCCESS) else { + throw OracleError(message: "Failed to set server attribute") + } + + // Allocate session handle + env = nil + status = OCIHandleAlloc( + envHandle, &env, UInt32(OCI_HTYPE_SESSION), 0, nil + ) + guard status == Int32(OCI_SUCCESS) else { + throw OracleError(message: "Failed to allocate session handle") + } + sesHandle = env?.assumingMemoryBound(to: OCISession.self) + + // Set username + status = user.withCString { cStr in + OCIAttrSet( + sesHandle, UInt32(OCI_HTYPE_SESSION), + UnsafeMutableRawPointer(mutating: cStr), UInt32(user.utf8.count), + UInt32(OCI_ATTR_USERNAME), errHandle + ) + } + guard status == Int32(OCI_SUCCESS) else { + throw OracleError(message: "Failed to set username") + } + + // Set password + status = password.withCString { cStr in + OCIAttrSet( + sesHandle, UInt32(OCI_HTYPE_SESSION), + UnsafeMutableRawPointer(mutating: cStr), UInt32(password.utf8.count), + UInt32(OCI_ATTR_PASSWORD), errHandle + ) + } + guard status == Int32(OCI_SUCCESS) else { + throw OracleError(message: "Failed to set password") + } + + // Begin session + status = OCISessionBegin( + svcHandle, errHandle, sesHandle, + UInt32(OCI_CRED_RDBMS), UInt32(OCI_DEFAULT) + ) + guard status == Int32(OCI_SUCCESS) || status == Int32(OCI_SUCCESS_WITH_INFO) else { + let detail = getErrorMessage() + throw OracleError(message: "Authentication failed \u{2014} \(detail)") + } + + // Set session on service context + status = OCIAttrSet( + svcHandle, UInt32(OCI_HTYPE_SVCCTX), + sesHandle, 0, UInt32(OCI_ATTR_SESSION), + errHandle + ) + guard status == Int32(OCI_SUCCESS) else { + throw OracleError(message: "Failed to set session attribute") + } + + lock.lock() + _isConnected = true + lock.unlock() + + logger.debug("Connected to Oracle \(self.host):\(self.port)/\(self.database)") + } + + func disconnect() { + lock.lock() + let wasConnected = _isConnected + _isConnected = false + lock.unlock() + + guard wasConnected else { return } + + queue.async { [self] in + if let ses = sesHandle, let svc = svcHandle, let err = errHandle { + _ = OCISessionEnd(svc, err, ses, UInt32(OCI_DEFAULT)) + } + if let srv = srvHandle, let err = errHandle { + _ = OCIServerDetach(srv, err, UInt32(OCI_DEFAULT)) + } + if let ses = sesHandle { _ = OCIHandleFree(ses, UInt32(OCI_HTYPE_SESSION)) } + if let svc = svcHandle { _ = OCIHandleFree(svc, UInt32(OCI_HTYPE_SVCCTX)) } + if let srv = srvHandle { _ = OCIHandleFree(srv, UInt32(OCI_HTYPE_SERVER)) } + if let err = errHandle { _ = OCIHandleFree(err, UInt32(OCI_HTYPE_ERROR)) } + if let env = envHandle { _ = OCIHandleFree(env, UInt32(OCI_HTYPE_ENV)) } + + self.sesHandle = nil + self.svcHandle = nil + self.srvHandle = nil + self.errHandle = nil + self.envHandle = nil + } + } + + // MARK: - Query Execution + + func executeQuery(_ query: String) async throws -> OracleQueryResult { + let queryToRun = String(query) + return try await withCheckedThrowingContinuation { [self] (cont: CheckedContinuation) in + queue.async { [self] in + do { + let result = try self.executeQuerySync(queryToRun) + cont.resume(returning: result) + } catch { + cont.resume(throwing: error) + } + } + } + } + + private func executeQuerySync(_ query: String) throws -> OracleQueryResult { + guard let svc = svcHandle, let err = errHandle, let env = envHandle else { + throw OracleError.notConnected + } + + // Allocate statement handle + var stmtRaw: UnsafeMutableRawPointer? + var status = OCIHandleAlloc(env, &stmtRaw, UInt32(OCI_HTYPE_STMT), 0, nil) + guard status == Int32(OCI_SUCCESS), let stmtPtr = stmtRaw else { + throw OracleError(message: "Failed to allocate statement handle") + } + let stmt = stmtPtr.assumingMemoryBound(to: OCIStmt.self) + defer { _ = OCIHandleFree(stmt, UInt32(OCI_HTYPE_STMT)) } + + // Prepare statement + status = query.withCString { cStr in + OCIStmtPrepare( + stmt, err, cStr, UInt32(query.utf8.count), + UInt32(OCI_DEFAULT), UInt32(OCI_DEFAULT) + ) + } + guard status == Int32(OCI_SUCCESS) else { + let detail = getErrorMessage() + throw OracleError(message: "Failed to prepare query: \(detail)") + } + + // Determine if this is a SELECT (iters=0) or DML (iters=1) + let isSelect = query.trimmingCharacters(in: .whitespacesAndNewlines) + .uppercased().hasPrefix("SELECT") + || query.trimmingCharacters(in: .whitespacesAndNewlines) + .uppercased().hasPrefix("WITH") + let iters: UInt32 = isSelect ? 0 : 1 + + // Execute + status = OCIStmtExecute( + svc, stmt, err, iters, 0, nil, nil, + UInt32(OCI_DEFAULT) + ) + guard status == Int32(OCI_SUCCESS) || status == Int32(OCI_SUCCESS_WITH_INFO) + || status == Int32(OCI_NO_DATA) else { + let detail = getErrorMessage() + throw OracleError(message: detail) + } + + // For non-SELECT, get affected row count + if !isSelect { + var rowCount: UInt32 = 0 + _ = OCIAttrGet( + stmt, UInt32(OCI_HTYPE_STMT), + &rowCount, nil, UInt32(OCI_ATTR_ROW_COUNT), err + ) + return OracleQueryResult(columns: [], columnTypeNames: [], rows: [], affectedRows: Int(rowCount)) + } + + // Get column count + var paramCount: UInt32 = 0 + _ = OCIAttrGet( + stmt, UInt32(OCI_HTYPE_STMT), + ¶mCount, nil, UInt32(OCI_ATTR_PARAM_COUNT), err + ) + + let numCols = Int(paramCount) + guard numCols > 0 else { + return OracleQueryResult(columns: [], columnTypeNames: [], rows: [], affectedRows: 0) + } + + // Describe columns and set up define buffers + var columns: [String] = [] + var typeNames: [String] = [] + let bufSize = 4_096 + var buffers: [[CChar]] = [] + var indicators: [Int16] = Array(repeating: 0, count: numCols) + var returnLengths: [UInt16] = Array(repeating: 0, count: numCols) + var defines: [UnsafeMutablePointer?] = Array(repeating: nil, count: numCols) + + for i in 1...numCols { + // Get parameter descriptor + var paramRaw: UnsafeMutableRawPointer? + _ = OCIParamGet(stmt, UInt32(OCI_HTYPE_STMT), err, ¶mRaw, UInt32(i)) + + // Get column name + var namePtr: UnsafeMutablePointer? + var nameLen: UInt32 = 0 + _ = OCIAttrGet( + paramRaw, UInt32(OCI_DTYPE_PARAM), + &namePtr, &nameLen, UInt32(OCI_ATTR_NAME), err + ) + let colName: String + if let namePtr, nameLen > 0 { + colName = String(cString: namePtr) + } else { + colName = "col\(i)" + } + columns.append(colName) + + // Get data type + var dataType: UInt16 = 0 + _ = OCIAttrGet( + paramRaw, UInt32(OCI_DTYPE_PARAM), + &dataType, nil, UInt32(OCI_ATTR_DATA_TYPE), err + ) + typeNames.append(oracleTypeName(Int32(dataType))) + + // Define output buffer — convert everything to string + var buf = [CChar](repeating: 0, count: bufSize) + buffers.append(buf) + } + + // Set up define by position for each column + for i in 0.. String? in + guard let base = bufPtr.baseAddress else { return nil } + return String(cString: base) + } + row.append(str) + } + } + allRows.append(row) + } + + return OracleQueryResult( + columns: columns, + columnTypeNames: typeNames, + rows: allRows, + affectedRows: allRows.count + ) + } + + // MARK: - Private Helpers + + private func getErrorMessage() -> String { + guard let err = errHandle else { return "Unknown error" } + var errCode: Int32 = 0 + var buf = [CChar](repeating: 0, count: 512) + _ = OCIErrorGet( + err, 1, nil, &errCode, &buf, UInt32(buf.count), UInt32(OCI_HTYPE_ERROR) + ) + return String(cString: buf).trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func oracleTypeName(_ type: Int32) -> String { + switch type { + case Int32(SQLT_CHR), Int32(SQLT_AFC), Int32(SQLT_AVC): return "varchar2" + case Int32(SQLT_NUM): return "number" + case Int32(SQLT_INT): return "integer" + case Int32(SQLT_FLT): return "float" + case Int32(SQLT_STR): return "string" + case Int32(SQLT_LNG): return "long" + case Int32(SQLT_RID), Int32(SQLT_RDD): return "rowid" + case Int32(SQLT_DAT): return "date" + case Int32(SQLT_BIN): return "raw" + case Int32(SQLT_LBI): return "long raw" + case Int32(SQLT_IBFLOAT): return "binary_float" + case Int32(SQLT_IBDOUBLE): return "binary_double" + case Int32(SQLT_CLOB): return "clob" + case Int32(SQLT_BLOB): return "blob" + case Int32(SQLT_BFILEE): return "bfile" + case Int32(SQLT_TIMESTAMP): return "timestamp" + case Int32(SQLT_TIMESTAMP_TZ): return "timestamp with time zone" + case Int32(SQLT_TIMESTAMP_LTZ): return "timestamp with local time zone" + case Int32(SQLT_INTERVAL_YM): return "interval year to month" + case Int32(SQLT_INTERVAL_DS): return "interval day to second" + default: return "unknown" + } + } +} diff --git a/TablePro/Core/Database/OracleDriver.swift b/TablePro/Core/Database/OracleDriver.swift new file mode 100644 index 00000000..8965a611 --- /dev/null +++ b/TablePro/Core/Database/OracleDriver.swift @@ -0,0 +1,485 @@ +// +// 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: OracleConnection? + + private(set) var currentSchema: String = "" + + var escapedSchema: String { + currentSchema.replacingOccurrences(of: "'", with: "''") + } + + var serverVersion: String? { + _serverVersion + } + private var _serverVersion: String? + + init(connection: DatabaseConnection) { + self.connection = connection + } + + // MARK: - Connection + + func connect() async throws { + status = .connecting + let conn = OracleConnection( + host: connection.host, + port: connection.port, + user: connection.username, + password: ConnectionStorage.shared.loadPassword(for: connection.id) ?? "", + database: connection.database + ) + 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() + let result = try await conn.executeQuery(query) + return mapToQueryResult(result, executionTime: Date().timeIntervalSince(startTime)) + } + + 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 DBA_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 { + // Oracle doesn't support CREATE DATABASE from a session. Create a schema (user) instead. + let quotedName = connection.type.quoteIdentifier(name) + _ = try await execute(query: "CREATE USER \(quotedName) IDENTIFIED BY temp_password DEFAULT TABLESPACE USERS QUOTA UNLIMITED ON USERS") + } + + 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 func mapToQueryResult(_ oracleResult: OracleQueryResult, executionTime: TimeInterval) -> QueryResult { + let columnTypes = oracleResult.columnTypeNames.map { rawType in + ColumnType(fromSQLiteType: 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 a074a153..e65a4be8 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, .cockroachdb, .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..87ea03da 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, @@ -158,7 +158,7 @@ struct SchemaStatementGenerator { isDestructive: old.dataType != new.dataType ) - case .postgresql, .redshift: + case .postgresql, .redshift, .cockroachdb: // PostgreSQL: Multiple ALTER COLUMN statements var statements: [String] = [] let oldQuoted = databaseType.quoteIdentifier(old.name) @@ -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 @@ -264,7 +293,7 @@ struct SchemaStatementGenerator { switch databaseType { case .mysql, .mariadb: parts.append("AUTO_INCREMENT") - case .postgresql, .redshift: + case .postgresql, .redshift, .cockroachdb: // PostgreSQL uses SERIAL or IDENTITY // For simplicity, we'll use SERIAL parts[1] = "SERIAL" @@ -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") } } @@ -289,11 +320,11 @@ struct SchemaStatementGenerator { case .mysql, .mariadb: let escapedComment = comment.replacingOccurrences(of: "'", with: "''") parts.append("COMMENT '\(escapedComment)'") - case .postgresql, .redshift: + case .postgresql, .redshift, .cockroachdb: // 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 } } @@ -316,11 +347,11 @@ struct SchemaStatementGenerator { let indexType = index.type.rawValue sql = "CREATE \(uniqueKeyword)INDEX \(indexQuoted) ON \(tableQuoted) (\(columnsQuoted)) USING \(indexType)" - case .postgresql, .redshift: + case .postgresql, .redshift, .cockroachdb: 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, .cockroachdb, .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, .cockroachdb, .mssql, .oracle: sql = "ALTER TABLE \(tableQuoted) DROP CONSTRAINT \(fkQuoted)" case .sqlite, .mongodb, .redis: throw DatabaseError.unsupportedOperation @@ -440,7 +471,7 @@ struct SchemaStatementGenerator { ALTER TABLE \(tableQuoted) ADD PRIMARY KEY (\(newColumnsQuoted)); """ - case .postgresql, .redshift: + case .postgresql, .redshift, .cockroachdb: // Use actual constraint name if available, otherwise fall back to convention let pkName = primaryKeyConstraintName ?? "\(tableName)_pkey" sql = """ @@ -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/ImportService.swift b/TablePro/Core/Services/ImportService.swift index 41b33385..2dc6e5f4 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, .cockroachdb, .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, .cockroachdb, .mssql, .oracle: return [] case .sqlite: return ["PRAGMA foreign_keys = ON"] @@ -315,8 +316,10 @@ final class ImportService { switch dbType { case .mysql, .mariadb: return "START TRANSACTION" - case .postgresql, .redshift, .sqlite: + case .postgresql, .redshift, .cockroachdb, .sqlite: return "BEGIN" + case .oracle: + return "SET TRANSACTION READ WRITE" case .mssql: return "BEGIN TRANSACTION" case .mongodb, .redis: diff --git a/TablePro/Core/Services/SQLDialectProvider.swift b/TablePro/Core/Services/SQLDialectProvider.swift index 25aede5e..ba9d512c 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 { @@ -286,7 +348,7 @@ struct SQLDialectFactory { switch databaseType { case .mysql, .mariadb: return MySQLDialect() - case .postgresql, .redshift: + case .postgresql, .redshift, .cockroachdb: return PostgreSQLDialect() case .sqlite: return SQLiteDialect() @@ -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/TableQueryBuilder.swift b/TablePro/Core/Services/TableQueryBuilder.swift index 5fe4e26f..dcd4bb52 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)" @@ -631,7 +650,7 @@ struct TableQueryBuilder { /// PostgreSQL and SQLite require an explicit ESCAPE declaration. private func buildLikeCondition(column: String, searchText: String) -> String { switch databaseType { - case .postgresql, .redshift: + case .postgresql, .redshift, .cockroachdb: return "\(column)::TEXT LIKE '%\(searchText)%' ESCAPE '\\'" case .mysql, .mariadb: return "CAST(\(column) AS CHAR) LIKE '%\(searchText)%'" @@ -639,6 +658,8 @@ struct TableQueryBuilder { return "\(column) LIKE '%\(searchText)%' ESCAPE '\\'" case .mssql: return "CAST(\(column) AS NVARCHAR(MAX)) LIKE '%\(searchText)%' ESCAPE '\\'" + case .oracle: + return "CAST(\(column) AS VARCHAR2(4000)) LIKE '%\(searchText)%' ESCAPE '\\'" } } @@ -680,4 +701,43 @@ struct TableQueryBuilder { 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 + } } diff --git a/TablePro/Core/Utilities/ConnectionURLFormatter.swift b/TablePro/Core/Utilities/ConnectionURLFormatter.swift index 5e1c56a6..0047543e 100644 --- a/TablePro/Core/Utilities/ConnectionURLFormatter.swift +++ b/TablePro/Core/Utilities/ConnectionURLFormatter.swift @@ -28,10 +28,12 @@ struct ConnectionURLFormatter { case .mariadb: return "mariadb" case .postgresql: return "postgresql" case .redshift: return "redshift" + case .cockroachdb: return "cockroachdb" case .sqlite: return "sqlite" case .mongodb: return "mongodb" case .redis: return "redis" case .mssql: return "sqlserver" + case .oracle: return "oracle" } } diff --git a/TablePro/Core/Utilities/ConnectionURLParser.swift b/TablePro/Core/Utilities/ConnectionURLParser.swift index 0f5be44a..bef588b0 100644 --- a/TablePro/Core/Utilities/ConnectionURLParser.swift +++ b/TablePro/Core/Utilities/ConnectionURLParser.swift @@ -100,8 +100,12 @@ struct ConnectionURLParser { dbType = .mongodb case "redis", "rediss": dbType = .redis + case "cockroachdb": + dbType = .cockroachdb case "sqlserver", "mssql", "jdbc:sqlserver": dbType = .mssql + case "oracle", "jdbc:oracle:thin": + dbType = .oracle default: return .failure(.unsupportedScheme(scheme)) } diff --git a/TablePro/Core/Utilities/SQLParameterInliner.swift b/TablePro/Core/Utilities/SQLParameterInliner.swift index 83396e95..f05800b5 100644 --- a/TablePro/Core/Utilities/SQLParameterInliner.swift +++ b/TablePro/Core/Utilities/SQLParameterInliner.swift @@ -19,9 +19,9 @@ struct SQLParameterInliner { /// - Returns: A SQL string with placeholders replaced by formatted literal values. static func inline(_ statement: ParameterizedStatement, databaseType: DatabaseType) -> String { switch databaseType { - case .postgresql, .redshift: + case .postgresql, .redshift, .cockroachdb: 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 86f296dc..3e51ebb9 100644 --- a/TablePro/Models/DatabaseConnection.swift +++ b/TablePro/Models/DatabaseConnection.swift @@ -103,9 +103,11 @@ enum DatabaseType: String, CaseIterable, Identifiable, Codable { case postgresql = "PostgreSQL" case sqlite = "SQLite" case redshift = "Redshift" + case cockroachdb = "CockroachDB" case mongodb = "MongoDB" case redis = "Redis" case mssql = "SQL Server" + case oracle = "Oracle" var id: String { rawValue } @@ -122,12 +124,16 @@ enum DatabaseType: String, CaseIterable, Identifiable, Codable { return "sqlite-icon" case .redshift: return "redshift-icon" + case .cockroachdb: + return "cockroachdb-icon" case .mongodb: return "mongodb-icon" case .redis: return "redis-icon" case .mssql: return "mssql-icon" + case .oracle: + return "oracle-icon" } } @@ -138,9 +144,11 @@ enum DatabaseType: String, CaseIterable, Identifiable, Codable { case .postgresql: return 5_432 case .sqlite: return 0 case .redshift: return 5_439 + case .cockroachdb: return 26_257 case .mongodb: return 27_017 case .redis: return 6_379 case .mssql: return 1_433 + case .oracle: return 1_521 } } @@ -149,7 +157,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, .cockroachdb, .mssql, .oracle: return true case .sqlite, .mongodb, .redis: return false } } @@ -157,7 +165,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, .cockroachdb, .mssql, .oracle: return true case .mongodb, .redis: return false @@ -167,7 +175,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, .cockroachdb, .mssql, .oracle: return true case .redshift, .mongodb, .redis: return false @@ -180,7 +188,7 @@ enum DatabaseType: String, CaseIterable, Identifiable, Codable { switch self { case .mysql, .mariadb, .sqlite: return "`" - case .postgresql, .redshift, .mongodb, .redis: + case .postgresql, .redshift, .cockroachdb, .mongodb, .redis, .oracle: return "\"" case .mssql: return "[" @@ -274,6 +282,7 @@ struct DatabaseConnection: Identifiable, Hashable { var mongoWriteConcern: String? var redisDatabase: Int? var mssqlSchema: String? + var oracleServiceName: String? init( id: UUID = UUID(), @@ -293,7 +302,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 @@ -313,6 +323,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/QueryTab.swift b/TablePro/Models/QueryTab.swift index 5a546361..9b72eb78 100644 --- a/TablePro/Models/QueryTab.swift +++ b/TablePro/Models/QueryTab.swift @@ -572,6 +572,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);" @@ -612,6 +615,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/Theme/Theme.swift b/TablePro/Theme/Theme.swift index 9bff0d20..56c6f6a6 100644 --- a/TablePro/Theme/Theme.swift +++ b/TablePro/Theme/Theme.swift @@ -22,6 +22,8 @@ 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 cockroachdbColor = Color(red: 0.24, green: 0.30, blue: 0.87) + static let oracleColor = Color(red: 0.76, green: 0.09, blue: 0.07) // #C3160B Oracle red // MARK: - Semantic Colors @@ -108,12 +110,16 @@ extension DatabaseType { return Theme.sqliteColor case .redshift: return Theme.redshiftColor + case .cockroachdb: + return Theme.cockroachdbColor case .mongodb: return Theme.mongodbColor case .redis: return Theme.redisColor case .mssql: return Theme.mssqlColor + case .oracle: + return Theme.oracleColor } } } diff --git a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift index 8ce6dbaf..3dae1b20 100644 --- a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift +++ b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift @@ -25,7 +25,7 @@ class DatabaseSwitcherViewModel { var showPreview = false /// Whether we're switching schemas (PostgreSQL) or databases (MySQL) - var isSchemaMode: Bool { databaseType == .postgresql || databaseType == .redshift } + var isSchemaMode: Bool { databaseType == .postgresql || databaseType == .redshift || databaseType == .cockroachdb } // MARK: - Dependencies @@ -157,6 +157,8 @@ class DatabaseSwitcherViewModel { return ["postgres", "template0", "template1"].contains(name) case .redshift: return ["dev", "padb_harvest"].contains(name) + case .cockroachdb: + return ["system", "defaultdb"].contains(name) case .sqlite: return false case .mongodb: @@ -165,6 +167,8 @@ 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 9f94155d..eff0376a 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -70,6 +70,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? @@ -462,6 +465,16 @@ struct ConnectionFormView: View { } } + if type == .oracle { + Section("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")) @@ -549,10 +562,12 @@ struct ConnectionFormView: View { case .mysql, .mariadb: return "3306" case .postgresql: return "5432" case .redshift: return "5439" + case .cockroachdb: return "26257" case .sqlite: return "" case .mongodb: return "27017" case .redis: return "6379" case .mssql: return "1433" + case .oracle: return "1521" } } @@ -623,6 +638,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 @@ -678,7 +696,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 @@ -776,7 +795,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/DatabaseSwitcher/DatabaseSwitcherSheet.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift index 1f9fd46c..c61c837d 100644 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift @@ -21,7 +21,7 @@ struct DatabaseSwitcherSheet: View { @State private var viewModel: DatabaseSwitcherViewModel @State private var showCreateDialog = false - private var isSchemaMode: Bool { databaseType == .postgresql || databaseType == .redshift } + private var isSchemaMode: Bool { databaseType == .postgresql || databaseType == .redshift || databaseType == .cockroachdb } init( isPresented: Binding, currentDatabase: String?, databaseType: DatabaseType, diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index e6691752..78688b4a 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -419,7 +419,7 @@ struct ExportDialog: View { var items: [ExportDatabaseItem] = [] switch connection.type { - case .postgresql, .redshift: + case .postgresql, .redshift, .cockroachdb: // PostgreSQL: fetch schemas within current database (can't query across databases) let schemas = try await fetchPostgreSQLSchemas(driver: driver) for schema in schemas { @@ -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() diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift index e1368b65..78d47dca 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift @@ -26,6 +26,8 @@ extension MainContentCoordinator { beginStatement = "START TRANSACTION" case .mssql: beginStatement = "BEGIN TRANSACTION" + case .oracle: + beginStatement = "SET TRANSACTION READ WRITE" default: beginStatement = "BEGIN" } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index ef2481f9..fc3be87e 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -170,6 +170,27 @@ extension MainContentCoordinator { WHERE schema = '\(schema)' ORDER BY "table" """ + case .cockroachdb: + let schema: String + if let crdbDriver = DatabaseManager.shared.driver(for: connectionId) as? CockroachDBDriver { + schema = crdbDriver.escapedSchema + } else { + schema = "public" + } + sql = """ + SELECT + schemaname as schema, + relname as name, + 'TABLE' as kind, + n_live_tup as estimated_rows, + pg_size_pretty(pg_total_relation_size(schemaname||'.'||relname)) as total_size, + pg_size_pretty(pg_relation_size(schemaname||'.'||relname)) as data_size, + pg_size_pretty(pg_indexes_size(schemaname||'.'||relname)) as index_size, + obj_description((schemaname||'.'||relname)::regclass) as comment + FROM pg_stat_user_tables + WHERE schemaname = '\(schema)' + ORDER BY relname + """ case .mysql, .mariadb: sql = """ SELECT @@ -224,6 +245,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})", @@ -301,12 +339,14 @@ extension MainContentCoordinator { // Reload schema for autocomplete. // session.tables was cleared above, which triggers SidebarView.loadTables() via onChange. await loadSchema() - } else if connection.type == .postgresql || connection.type == .redshift { + } else if connection.type == .postgresql || connection.type == .redshift || connection.type == .cockroachdb { // PostgreSQL: switch schema (not database — PG database switching requires reconnection) if let pgDriver = driver as? PostgreSQLDriver { try await pgDriver.switchSchema(to: database) } else if let rsDriver = driver as? RedshiftDriver { try await rsDriver.switchSchema(to: database) + } else if let crdbDriver = driver as? CockroachDBDriver { + try await crdbDriver.switchSchema(to: database) } else { return } @@ -316,6 +356,8 @@ extension MainContentCoordinator { try? await pgMeta.switchSchema(to: database) } else if let rsMeta = DatabaseManager.shared.metadataDriver(for: connectionId) as? RedshiftDriver { try? await rsMeta.switchSchema(to: database) + } else if let crdbMeta = DatabaseManager.shared.metadataDriver(for: connectionId) as? CockroachDBDriver { + try? await crdbMeta.switchSchema(to: database) } // Update session @@ -338,6 +380,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+SQLPreview.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SQLPreview.swift index f4858b26..b38817e8 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SQLPreview.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SQLPreview.swift @@ -77,7 +77,12 @@ extension MainContentCoordinator { // Wrap all operations in a single transaction when we have multiple operations let needsTransaction = hasEditedCells && hasPendingTableOps if needsTransaction { - let beginSQL = dbType == .mssql ? "BEGIN TRANSACTION" : "BEGIN" + let beginSQL: String + switch dbType { + case .mssql: beginSQL = "BEGIN TRANSACTION" + case .oracle: beginSQL = "SET TRANSACTION READ WRITE" + default: beginSQL = "BEGIN" + } allStatements.append(ParameterizedStatement(sql: beginSQL, parameters: [])) } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift index 4ea9e276..730f34fb 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 != .cockroachdb && dbType != .mssql && dbType != .oracle && truncates.union(deletes).contains { tableName in options[tableName]?.ignoreForeignKeys == true } @@ -45,7 +45,14 @@ extension MainContentCoordinator { // Wrap in transaction for atomicity let needsTransaction = wrapInTransaction && (sortedTruncates.count + sortedDeletes.count) > 1 if needsTransaction { - statements.append(dbType == .mssql ? "BEGIN TRANSACTION" : "BEGIN") + switch dbType { + case .mssql: + statements.append("BEGIN TRANSACTION") + case .oracle: + statements.append("SET TRANSACTION READ WRITE") + default: + statements.append("BEGIN") + } } for tableName in sortedTruncates { @@ -84,7 +91,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, .cockroachdb, .mongodb, .redis, .mssql, .oracle: return [] case .sqlite: return ["PRAGMA foreign_keys = OFF"] } } @@ -94,7 +101,7 @@ extension MainContentCoordinator { switch dbType { case .mysql, .mariadb: return ["SET FOREIGN_KEY_CHECKS=1"] - case .postgresql, .redshift, .mongodb, .redis, .mssql: + case .postgresql, .redshift, .cockroachdb, .mongodb, .redis, .mssql, .oracle: return [] case .sqlite: return ["PRAGMA foreign_keys = ON"] @@ -109,10 +116,10 @@ extension MainContentCoordinator { switch dbType { case .mysql, .mariadb: return ["TRUNCATE TABLE \(quotedName)"] - case .postgresql, .redshift: + case .postgresql, .redshift, .cockroachdb: 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. @@ -139,9 +146,9 @@ extension MainContentCoordinator { private func dropTableStatement(tableName: String, quotedName: String, isView: Bool, options: TableOperationOptions, dbType: DatabaseType) -> String { let keyword = isView ? "VIEW" : "TABLE" switch dbType { - case .postgresql, .redshift: + case .postgresql, .redshift, .cockroachdb: 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 00b6454a..7f615d7a 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 @@ -305,7 +308,7 @@ final class MainContentCommandActions { let template: String switch connection.type { - case .postgresql, .redshift: + case .postgresql, .redshift, .cockroachdb: template = "CREATE OR REPLACE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;" case .mysql, .mariadb: template = "CREATE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;" @@ -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: @@ -609,7 +614,7 @@ final class MainContentCommandActions { } catch { let fallbackSQL: String switch connection.type { - case .postgresql, .redshift: + case .postgresql, .redshift, .cockroachdb: fallbackSQL = "CREATE OR REPLACE VIEW \(viewName) AS\n-- Could not fetch view definition: \(error.localizedDescription)\nSELECT * FROM table_name;" case .mysql, .mariadb: fallbackSQL = "ALTER VIEW \(viewName) AS\n-- Could not fetch view definition: \(error.localizedDescription)\nSELECT * FROM table_name;" @@ -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 09a36fcd..f08aa586 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -89,7 +89,7 @@ final class MainContentCoordinator { @ObservationIgnored private var activeSortTasks: [UUID: Task] = [:] /// 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. @@ -191,9 +191,10 @@ final class MainContentCoordinator { for tab in tabManager.tabs { tab.rowBuffer.evict() } + querySortCache.removeAll() + tabManager.tabs.removeAll() tabManager.selectedTabId = nil - querySortCache.removeAll() Self.releaseSchemaProvider(for: connection.id) Self.purgeUnusedSchemaProviders() @@ -429,11 +430,11 @@ 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)" - case .mysql, .mariadb, .postgresql, .redshift: + case .mysql, .mariadb, .postgresql, .redshift, .cockroachdb: explainSQL = "EXPLAIN \(stmt)" case .mongodb: explainSQL = Self.buildMongoExplain(for: stmt) @@ -1189,9 +1190,9 @@ final class MainContentCoordinator { count -= 1 if count <= 0 { schemaProviderRefCounts.removeValue(forKey: connectionId) - // Grace period: keep provider alive for 5s in case a new tab opens quickly + // Grace period: keep provider alive for 1s in case a new tab opens quickly schemaProviderRemovalTasks[connectionId] = Task { @MainActor in - try? await Task.sleep(nanoseconds: 5_000_000_000) + try? await Task.sleep(nanoseconds: 1_000_000_000) guard !Task.isCancelled else { return } sharedSchemaProviders.removeValue(forKey: connectionId) schemaProviderRemovalTasks.removeValue(forKey: connectionId) diff --git a/TablePro/Views/Sidebar/TableOperationDialog.swift b/TablePro/Views/Sidebar/TableOperationDialog.swift index ba8cfc6c..93c04085 100644 --- a/TablePro/Views/Sidebar/TableOperationDialog.swift +++ b/TablePro/Views/Sidebar/TableOperationDialog.swift @@ -46,7 +46,7 @@ struct TableOperationDialog: View { // PostgreSQL supports CASCADE for both DROP and TRUNCATE. // MySQL, MariaDB, and SQLite do not support CASCADE for these operations. switch databaseType { - case .postgresql, .redshift: + case .postgresql, .redshift, .cockroachdb: return true default: return false @@ -79,11 +79,11 @@ 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 == .cockroachdb } private var ignoreFKDescription: String? { - if databaseType == .postgresql || databaseType == .redshift { + if databaseType == .postgresql || databaseType == .redshift || databaseType == .cockroachdb { return "Not supported for PostgreSQL. Use CASCADE instead." } return nil diff --git a/TablePro/Views/Structure/TypePickerContentView.swift b/TablePro/Views/Structure/TypePickerContentView.swift index 2f780b10..47c8cb5b 100644 --- a/TablePro/Views/Structure/TypePickerContentView.swift +++ b/TablePro/Views/Structure/TypePickerContentView.swift @@ -21,10 +21,12 @@ enum DataTypeCategory: String, CaseIterable { switch dbType { case .mysql, .mariadb: return ["TINYINT", "SMALLINT", "MEDIUMINT", "INT", "BIGINT", "DECIMAL", "NUMERIC", "FLOAT", "DOUBLE", "BIT"] - case .postgresql, .redshift: + case .postgresql, .redshift, .cockroachdb: 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: @@ -36,10 +38,12 @@ enum DataTypeCategory: String, CaseIterable { switch dbType { case .mysql, .mariadb: return ["CHAR", "VARCHAR", "TINYTEXT", "TEXT", "MEDIUMTEXT", "LONGTEXT"] - case .postgresql, .redshift: + case .postgresql, .redshift, .cockroachdb: 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: @@ -51,10 +55,12 @@ enum DataTypeCategory: String, CaseIterable { switch dbType { case .mysql, .mariadb: return ["DATE", "TIME", "DATETIME", "TIMESTAMP", "YEAR"] - case .postgresql, .redshift: + case .postgresql, .redshift, .cockroachdb: 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: @@ -66,10 +72,12 @@ enum DataTypeCategory: String, CaseIterable { switch dbType { case .mysql, .mariadb: return ["BINARY", "VARBINARY", "TINYBLOB", "BLOB", "MEDIUMBLOB", "LONGBLOB"] - case .postgresql, .redshift: + case .postgresql, .redshift, .cockroachdb: return ["BYTEA"] case .mssql: return ["BINARY", "VARBINARY", "IMAGE"] + case .oracle: + return ["BLOB", "RAW", "LONG RAW", "BFILE"] case .sqlite: return ["BLOB"] case .mongodb: @@ -81,10 +89,12 @@ enum DataTypeCategory: String, CaseIterable { switch dbType { case .mysql, .mariadb: return ["BOOLEAN", "ENUM", "SET", "JSON"] - case .postgresql, .redshift: + case .postgresql, .redshift, .cockroachdb: 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: From 469e1c71cb852278b29085f89918a26f649d3f56 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 6 Mar 2026 01:42:08 +0700 Subject: [PATCH 03/10] fix: complete Oracle integration gaps - ConnectionStorage: persist oracleServiceName in StoredConnection - OracleConnection: add serviceName parameter for OCI connect string - OracleDriver: pass oracleServiceName to OracleConnection - DatabaseManager: add Oracle schema init in connectToSession - AppDelegate: register oracle URL scheme - MultiStatement: use SET TRANSACTION READ WRITE for Oracle --- TablePro/AppDelegate.swift | 3 ++- TablePro/Core/Database/DatabaseManager.swift | 2 ++ TablePro/Core/Database/OracleConnection.swift | 7 +++++-- TablePro/Core/Database/OracleDriver.swift | 3 ++- TablePro/Core/Storage/ConnectionStorage.swift | 10 +++++++++- .../MainContentCoordinator+MultiStatement.swift | 2 ++ 6 files changed, 22 insertions(+), 5 deletions(-) diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index 266b6645..7a3f7ec4 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -37,7 +37,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { private static let databaseURLSchemes: Set = [ "postgresql", "postgres", "mysql", "mariadb", "sqlite", - "mongodb", "redis", "rediss", "redshift", "cockroachdb" + "mongodb", "redis", "rediss", "redshift", "cockroachdb", + "oracle" ] func applicationDockMenu(_ sender: NSApplication) -> NSMenu? { diff --git a/TablePro/Core/Database/DatabaseManager.swift b/TablePro/Core/Database/DatabaseManager.swift index 76fe985c..e0484400 100644 --- a/TablePro/Core/Database/DatabaseManager.swift +++ b/TablePro/Core/Database/DatabaseManager.swift @@ -141,6 +141,8 @@ final class DatabaseManager { activeSessions[connection.id]?.currentSchema = rsDriver.currentSchema } else if let crdbDriver = driver as? CockroachDBDriver { activeSessions[connection.id]?.currentSchema = crdbDriver.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 diff --git a/TablePro/Core/Database/OracleConnection.swift b/TablePro/Core/Database/OracleConnection.swift index 9b8473f5..3972e007 100644 --- a/TablePro/Core/Database/OracleConnection.swift +++ b/TablePro/Core/Database/OracleConnection.swift @@ -51,6 +51,7 @@ final class OracleConnection: @unchecked Sendable { private let user: String private let password: String private let database: String + private let serviceName: String private let lock = NSLock() private var _isConnected = false @@ -63,13 +64,14 @@ final class OracleConnection: @unchecked Sendable { // MARK: - Initialization - init(host: String, port: Int, user: String, password: String, database: String) { + init(host: String, port: Int, user: String, password: String, database: String, serviceName: String = "") { self.queue = DispatchQueue(label: "com.TablePro.oracle.\(host).\(port)", qos: .userInitiated) self.host = host self.port = port self.user = user self.password = password self.database = database + self.serviceName = serviceName } // MARK: - Connection @@ -118,7 +120,8 @@ final class OracleConnection: @unchecked Sendable { srvHandle = env?.assumingMemoryBound(to: OCIServer.self) // Build connect string: //host:port/service_name - let connectString = "//\(host):\(port)/\(database)" + let service = serviceName.isEmpty ? database : serviceName + let connectString = "//\(host):\(port)/\(service)" // Attach to server status = connectString.withCString { cStr in diff --git a/TablePro/Core/Database/OracleDriver.swift b/TablePro/Core/Database/OracleDriver.swift index 8965a611..fa57bd74 100644 --- a/TablePro/Core/Database/OracleDriver.swift +++ b/TablePro/Core/Database/OracleDriver.swift @@ -40,7 +40,8 @@ final class OracleDriver: DatabaseDriver { port: connection.port, user: connection.username, password: ConnectionStorage.shared.loadPassword(for: connection.id) ?? "", - database: connection.database + database: connection.database, + serviceName: connection.oracleServiceName ?? "" ) do { try await conn.connect() diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index 45fffb0c..3d314093 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -361,6 +361,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 @@ -398,6 +401,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 @@ -435,6 +441,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 { @@ -475,7 +482,8 @@ private struct StoredConnection: Codable { groupId: parsedGroupId, isReadOnly: isReadOnly, aiPolicy: parsedAIPolicy, - mssqlSchema: mssqlSchema + mssqlSchema: mssqlSchema, + oracleServiceName: oracleServiceName ) } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift index cace0599..9573d9d0 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift @@ -164,6 +164,8 @@ extension MainContentCoordinator { switch dbType { case .mysql, .mariadb: beginSQL = "START TRANSACTION" + case .oracle: + beginSQL = "SET TRANSACTION READ WRITE" default: beginSQL = "BEGIN" } From 68de4857d49323da8bed77209b1cfd8f8c21fe70 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 6 Mar 2026 01:44:39 +0700 Subject: [PATCH 04/10] docs: add Oracle Database documentation and landing page --- docs/databases/connection-urls.mdx | 3 + docs/databases/oracle.mdx | 295 ++++++++++++++++++++++++++ docs/databases/overview.mdx | 16 +- docs/docs.json | 4 + docs/vi/databases/connection-urls.mdx | 3 + docs/vi/databases/oracle.mdx | 295 ++++++++++++++++++++++++++ docs/vi/databases/overview.mdx | 16 +- 7 files changed, 626 insertions(+), 6 deletions(-) create mode 100644 docs/databases/oracle.mdx create mode 100644 docs/vi/databases/oracle.mdx diff --git a/docs/databases/connection-urls.mdx b/docs/databases/connection-urls.mdx index 0a28d528..d2c1eff0 100644 --- a/docs/databases/connection-urls.mdx +++ b/docs/databases/connection-urls.mdx @@ -21,8 +21,11 @@ TablePro supports standard database connection URLs for importing connections, o | `redis://` | Redis | | `rediss://` | Redis with TLS | | `redshift://` | Amazon Redshift | +| `cockroachdb://` | CockroachDB | | `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 25027e98..6e8876cf 100644 --- a/docs/databases/overview.mdx +++ b/docs/databases/overview.mdx @@ -9,7 +9,7 @@ TablePro provides a single interface for managing all your database connections. ## Supported Databases -TablePro supports eight database systems: +TablePro supports nine database systems: @@ -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`, `redis`, `rediss`, `redshift`, `cockroachdb`, 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 automatically sets the default port when you select a database type: | MongoDB | 27017 | | Redis | 6379 | | Microsoft SQL Server | 1433 | +| Oracle Database | 1521 | ## Related Guides @@ -557,9 +561,15 @@ TablePro automatically sets the default port when you select a database type: SQL Server connections via FreeTDS + + Oracle Database connections via OCI + Redshift data warehouse connections + + CockroachDB distributed SQL connections + Secure connections through SSH diff --git a/docs/docs.json b/docs/docs.json index be3d1b62..69a017a5 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -39,7 +39,9 @@ "databases/mongodb", "databases/redis", "databases/redshift", + "databases/cockroachdb", "databases/mssql", + "databases/oracle", "databases/ssh-tunneling" ] }, @@ -127,7 +129,9 @@ "vi/databases/mongodb", "vi/databases/redis", "vi/databases/redshift", + "vi/databases/cockroachdb", "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 1387fde2..ec79df23 100644 --- a/docs/vi/databases/connection-urls.mdx +++ b/docs/vi/databases/connection-urls.mdx @@ -21,8 +21,11 @@ TablePro hỗ trợ các URL kết nối cơ sở dữ liệu chuẩn để nh | `redis://` | Redis | | `rediss://` | Redis với TLS | | `redshift://` | Amazon Redshift | +| `cockroachdb://` | CockroachDB | | `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) để sử 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 fe13fae7..6f9d01a7 100644 --- a/docs/vi/databases/overview.mdx +++ b/docs/vi/databases/overview.mdx @@ -9,7 +9,7 @@ TablePro cung cấp giao diện được tối ưu hóa để quản lý tất c ## Cơ sở dữ liệu được hỗ trợ -TablePro hỗ trợ tám hệ thống cơ sở dữ liệu: +TablePro hỗ trợ chín hệ thống cơ sở dữ liệu: @@ -36,6 +36,9 @@ TablePro hỗ trợ tám 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à 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. +TablePro đăng ký `postgresql`, `postgres`, `mysql`, `mariadb`, `sqlite`, `mongodb`, `redis`, `rediss`, `redshift`, `cockroachdb` 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. **Điều gì xảy ra:** @@ -122,7 +125,7 @@ Xem [Tham chiếu URL kết nối](/databases/connection-urls) để biết tấ | Trường | Mô tả | |-------|-------------| | **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 hoặc SQLite | +| **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 đặt cổng mặc định khi bạn chọn loại cơ s | MongoDB | 27017 | | Redis | 6379 | | Microsoft SQL Server | 1433 | +| Oracle Database | 1521 | ## Hướng dẫn Liên quan @@ -557,9 +561,15 @@ TablePro tự động đặt cổng mặc định khi bạn chọn loại cơ s 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 + + Kết nối CockroachDB distributed SQL + Kết nối an toàn thông qua SSH From 3e77992d6cbc26f65ada97fb5f441fdd58256da6 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 6 Mar 2026 09:19:29 +0700 Subject: [PATCH 05/10] fix: address PR review feedback for Oracle support --- CHANGELOG.md | 9 - TablePro/Core/Database/OracleConnection.swift | 60 +++-- TablePro/Core/Database/OracleDriver.swift | 4 +- .../Views/Connection/ConnectionFormView.swift | 2 +- docs/databases/cockroachdb.mdx | 224 ++++++++++++++++++ docs/vi/databases/cockroachdb.mdx | 224 ++++++++++++++++++ 6 files changed, 489 insertions(+), 34 deletions(-) create mode 100644 docs/databases/cockroachdb.mdx create mode 100644 docs/vi/databases/cockroachdb.mdx diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ab82ec8..a4b290ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,15 +13,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - CockroachDB database support (PostgreSQL wire-compatible via LibPQ) - Add database and schema switching for PostgreSQL connections via ⌘K -### 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 - ## [0.14.0] - 2026-03-05 ### Added diff --git a/TablePro/Core/Database/OracleConnection.swift b/TablePro/Core/Database/OracleConnection.swift index 3972e007..6607bb76 100644 --- a/TablePro/Core/Database/OracleConnection.swift +++ b/TablePro/Core/Database/OracleConnection.swift @@ -90,10 +90,26 @@ final class OracleConnection: @unchecked Sendable { } private func connectSync() throws { + var didConnect = false + defer { + if !didConnect { + if let ses = sesHandle { OCIHandleFree(ses, UInt32(OCI_HTYPE_SESSION)) } + if let svc = svcHandle { OCIHandleFree(svc, UInt32(OCI_HTYPE_SVCCTX)) } + if let srv = srvHandle { OCIHandleFree(srv, UInt32(OCI_HTYPE_SERVER)) } + if let err = errHandle { OCIHandleFree(err, UInt32(OCI_HTYPE_ERROR)) } + if let env = envHandle { OCIHandleFree(env, UInt32(OCI_HTYPE_ENV)) } + sesHandle = nil + svcHandle = nil + srvHandle = nil + errHandle = nil + envHandle = nil + } + } + // Create OCI environment var env: UnsafeMutableRawPointer? var status = OCIEnvCreate( - &envHandle, UInt32(OCI_THREADED), + &envHandle, UInt32(OCI_THREADED | OCI_OBJECT), nil, nil, nil, nil, 0, nil ) guard status == Int32(OCI_SUCCESS), envHandle != nil else { @@ -133,7 +149,7 @@ final class OracleConnection: @unchecked Sendable { } guard status == Int32(OCI_SUCCESS) || status == Int32(OCI_SUCCESS_WITH_INFO) else { let detail = getErrorMessage() - throw OracleError(message: "Failed to connect to \(host):\(port) \u{2014} \(detail)") + throw OracleError(message: "Failed to connect to \(host):\(port): \(detail)") } // Allocate service context @@ -197,7 +213,7 @@ final class OracleConnection: @unchecked Sendable { ) guard status == Int32(OCI_SUCCESS) || status == Int32(OCI_SUCCESS_WITH_INFO) else { let detail = getErrorMessage() - throw OracleError(message: "Authentication failed \u{2014} \(detail)") + throw OracleError(message: "Authentication failed: \(detail)") } // Set session on service context @@ -214,29 +230,31 @@ final class OracleConnection: @unchecked Sendable { _isConnected = true lock.unlock() + didConnect = true logger.debug("Connected to Oracle \(self.host):\(self.port)/\(self.database)") } func disconnect() { lock.lock() - let wasConnected = _isConnected + guard _isConnected else { + lock.unlock() + return + } _isConnected = false lock.unlock() - guard wasConnected else { return } - - queue.async { [self] in - if let ses = sesHandle, let svc = svcHandle, let err = errHandle { - _ = OCISessionEnd(svc, err, ses, UInt32(OCI_DEFAULT)) + queue.sync { [self] in + if let ses = self.sesHandle { + OCISessionEnd(self.svcHandle, self.errHandle, ses, UInt32(OCI_DEFAULT)) } - if let srv = srvHandle, let err = errHandle { - _ = OCIServerDetach(srv, err, UInt32(OCI_DEFAULT)) + if let srv = self.srvHandle { + OCIServerDetach(srv, self.errHandle, UInt32(OCI_DEFAULT)) } - if let ses = sesHandle { _ = OCIHandleFree(ses, UInt32(OCI_HTYPE_SESSION)) } - if let svc = svcHandle { _ = OCIHandleFree(svc, UInt32(OCI_HTYPE_SVCCTX)) } - if let srv = srvHandle { _ = OCIHandleFree(srv, UInt32(OCI_HTYPE_SERVER)) } - if let err = errHandle { _ = OCIHandleFree(err, UInt32(OCI_HTYPE_ERROR)) } - if let env = envHandle { _ = OCIHandleFree(env, UInt32(OCI_HTYPE_ENV)) } + if let ses = self.sesHandle { OCIHandleFree(ses, UInt32(OCI_HTYPE_SESSION)) } + if let svc = self.svcHandle { OCIHandleFree(svc, UInt32(OCI_HTYPE_SVCCTX)) } + if let srv = self.srvHandle { OCIHandleFree(srv, UInt32(OCI_HTYPE_SERVER)) } + if let err = self.errHandle { OCIHandleFree(err, UInt32(OCI_HTYPE_ERROR)) } + if let env = self.envHandle { OCIHandleFree(env, UInt32(OCI_HTYPE_ENV)) } self.sesHandle = nil self.svcHandle = nil @@ -288,11 +306,11 @@ final class OracleConnection: @unchecked Sendable { throw OracleError(message: "Failed to prepare query: \(detail)") } - // Determine if this is a SELECT (iters=0) or DML (iters=1) - let isSelect = query.trimmingCharacters(in: .whitespacesAndNewlines) - .uppercased().hasPrefix("SELECT") - || query.trimmingCharacters(in: .whitespacesAndNewlines) - .uppercased().hasPrefix("WITH") + // Use OCI statement type detection instead of string matching + var stmtType: UInt16 = 0 + var stmtTypeSize: UInt32 = UInt32(MemoryLayout.size) + OCIAttrGet(stmt, UInt32(OCI_HTYPE_STMT), &stmtType, &stmtTypeSize, UInt32(OCI_ATTR_STMT_TYPE), err) + let isSelect = stmtType == UInt16(OCI_STMT_SELECT) let iters: UInt32 = isSelect ? 0 : 1 // Execute diff --git a/TablePro/Core/Database/OracleDriver.swift b/TablePro/Core/Database/OracleDriver.swift index fa57bd74..05adf174 100644 --- a/TablePro/Core/Database/OracleDriver.swift +++ b/TablePro/Core/Database/OracleDriver.swift @@ -451,9 +451,7 @@ final class OracleDriver: DatabaseDriver { } func createDatabase(name: String, charset: String, collation: String?) async throws { - // Oracle doesn't support CREATE DATABASE from a session. Create a schema (user) instead. - let quotedName = connection.type.quoteIdentifier(name) - _ = try await execute(query: "CREATE USER \(quotedName) IDENTIFIED BY temp_password DEFAULT TABLESPACE USERS QUOTA UNLIMITED ON USERS") + throw DatabaseError.unsupportedOperation } func cancelQuery() throws { diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index eff0376a..b7973fa4 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -466,7 +466,7 @@ struct ConnectionFormView: View { } if type == .oracle { - Section("Oracle") { + Section(String(localized: "Oracle")) { TextField(String(localized: "Service Name"), text: Binding( get: { oracleServiceName }, set: { oracleServiceName = $0 } diff --git a/docs/databases/cockroachdb.mdx b/docs/databases/cockroachdb.mdx new file mode 100644 index 00000000..88378019 --- /dev/null +++ b/docs/databases/cockroachdb.mdx @@ -0,0 +1,224 @@ +--- +title: CockroachDB +description: Connect to CockroachDB databases with TablePro +--- + +# CockroachDB Connections + +TablePro supports CockroachDB, a distributed SQL database built on PostgreSQL wire protocol. Connections work through the same libpq driver used for PostgreSQL, with CockroachDB-specific metadata queries. + +## Quick Setup + + + + Click **New Connection** from the Welcome screen or **File** > **New Connection** + + + Choose **CockroachDB** from the database type selector + + + Fill in host, port, username, password, and database name + + + Click **Test Connection**, then **Create** + + + +## Connection Settings + +### Required Fields + +| Field | Description | Default | +|-------|-------------|---------| +| **Name** | Connection identifier | - | +| **Host** | Server hostname or IP | `localhost` | +| **Port** | CockroachDB SQL port | `26257` | +| **Username** | Database user | `root` | +| **Password** | User password | - | +| **Database** | Database name | `defaultdb` | + + +Local CockroachDB instances started with `cockroach demo` or `cockroach start-single-node --insecure` accept connections from `root` without a password on port 26257. + + +## Connection URL Format + +You can import connections using a CockroachDB URL. + +See [Connection URL Reference](/databases/connection-urls#cockroachdb) for the full URL format. + +## Example Configurations + +### Local Development (Single Node) + +``` +Name: Local CockroachDB +Host: localhost +Port: 26257 +Username: root +Password: (empty for insecure mode) +Database: defaultdb +``` + +### CockroachDB Cloud (Serverless) + +``` +Name: CockroachDB Cloud +Host: free-tier.gcp-us-central1.cockroachlabs.cloud +Port: 26257 +Username: your_user +Password: (your password) +Database: defaultdb +``` + +### Docker Container + +``` +Name: Docker CockroachDB +Host: localhost +Port: 26257 (or your mapped port) +Username: root +Password: (empty for insecure mode) +Database: defaultdb +``` + + +CockroachDB Cloud requires SSL/TLS. Download your cluster's CA certificate from the CockroachDB Cloud Console and configure it in the SSL/TLS section. + + +## Features + +### Schema Support + +CockroachDB organizes tables into schemas, following PostgreSQL conventions. TablePro displays all schemas accessible to your user and their tables. + +### Schema Switching + +Switch between schemas using the database switcher (**Cmd+K**): + +1. Press **Cmd+K** to open the switcher +2. Select the target schema +3. The sidebar, queries, and toolbar update to reflect the selected schema + +### Table Browsing and Data Viewing + +Browse tables, views, and sequences. The data grid supports pagination and inline editing. Primary key and index information is displayed in the Structure tab. + +### Query Execution + +Execute queries with CockroachDB SQL syntax. The SQL editor provides PostgreSQL-compatible syntax highlighting. + +```sql +-- CockroachDB-specific: SHOW RANGES +SHOW RANGES FROM TABLE users; + +-- Serial columns use unique_rowid() +CREATE TABLE events ( + id INT DEFAULT unique_rowid() PRIMARY KEY, + name STRING NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() +); + +-- Import data +IMPORT INTO users CSV DATA ('nodelocal://1/users.csv'); +``` + +### Data Export + +Export query results or table data in multiple formats: + +- CSV +- JSON +- SQL (INSERT statements) +- XLSX + +### Data Import + +Import data from CSV, JSON, SQL, and XLSX files into CockroachDB tables. + +### DDL Generation + +View the CREATE TABLE statement for any table, including CockroachDB-specific clauses. + +## SSL/TLS + +CockroachDB connections support SSL/TLS encryption, using the same configuration as PostgreSQL: + +| SSL Mode | Description | +|----------|-------------| +| **Disabled** | No SSL encryption | +| **Preferred** | Use SSL if available, fall back to unencrypted | +| **Required** | Require SSL, but don't verify certificates | +| **Verify CA** | Require SSL and verify the server certificate against a CA | +| **Verify Identity** | Require SSL, verify CA, and verify the server hostname | + + +CockroachDB Cloud (serverless and dedicated) requires SSL. Download the CA certificate from the Cloud Console and use **Verify CA** mode. + + +## SSH Tunnel Support + +You can connect to CockroachDB through an SSH tunnel for secure access to remote servers. See [SSH Tunneling](/databases/ssh-tunneling) for detailed setup instructions. + +## Differences from PostgreSQL + +CockroachDB is PostgreSQL wire-compatible but has some differences: + +| PostgreSQL | CockroachDB | +|------------|-------------| +| Default port 5432 | Default port 26257 | +| `SERIAL` type | `INT DEFAULT unique_rowid()` or `UUID DEFAULT gen_random_uuid()` | +| `ENUM` via `CREATE TYPE` | `CREATE TYPE ... AS ENUM` (supported since v20.2) | +| Single-node by default | Distributed by default | +| `pg_stat_activity` | `crdb_internal.node_sessions` | +| Triggers | Not supported | +| Stored procedures | Limited PL/pgSQL support (since v23.1) | + +## Troubleshooting + +### Connection Refused + +**Symptoms**: "Connection refused" or timeout + +**Common causes**: + +1. **CockroachDB not running**: Start the node with `cockroach start-single-node` or `cockroach demo` +2. **Wrong port**: Verify the SQL port (default 26257, not the HTTP admin port 8080) +3. **Firewall rules**: Ensure port 26257 is open for your client IP + +### Authentication Failed + +**Symptoms**: "password authentication failed" + +**Solutions**: + +1. For insecure mode, ensure you started CockroachDB with `--insecure` +2. For secure mode, verify your username and password +3. Check that the user has been granted access: `SHOW GRANTS ON DATABASE defaultdb` + +### SSL Certificate Errors + +**Symptoms**: "certificate verify failed" or SSL handshake errors + +**Solutions**: + +1. Download the correct CA certificate from the CockroachDB Cloud Console +2. For self-hosted clusters, use the CA cert generated during `cockroach cert create-ca` +3. Set SSL mode to **Verify CA** and point to the CA certificate file + +## Next Steps + + + + Connect securely to remote CockroachDB clusters + + + Write and execute CockroachDB queries + + + Import and export CockroachDB data + + + Browse and edit data in the data grid + + diff --git a/docs/vi/databases/cockroachdb.mdx b/docs/vi/databases/cockroachdb.mdx new file mode 100644 index 00000000..71fb5878 --- /dev/null +++ b/docs/vi/databases/cockroachdb.mdx @@ -0,0 +1,224 @@ +--- +title: CockroachDB +description: Kết nối đến cơ sở dữ liệu CockroachDB với TablePro +--- + +# Kết nối CockroachDB + +TablePro hỗ trợ CockroachDB, cơ sở dữ liệu SQL phân tán dựa trên giao thức PostgreSQL wire. Các kết nối hoạt động qua cùng driver libpq được sử dụng cho PostgreSQL, với các truy vấn metadata riêng cho CockroachDB. + +## Thiết lập nhanh + + + + Click **New Connection** từ màn hình Welcome hoặc **File** > **New Connection** + + + Chọn **CockroachDB** từ danh sách loại cơ sở dữ liệu + + + Điền host, port, username, password và tên database + + + 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 IP của server | `localhost` | +| **Port** | Port SQL của CockroachDB | `26257` | +| **Username** | Tên người dùng | `root` | +| **Password** | Mật khẩu | - | +| **Database** | Tên database | `defaultdb` | + + +Các instance CockroachDB cục bộ khởi động bằng `cockroach demo` hoặc `cockroach start-single-node --insecure` chấp nhận kết nối từ `root` không cần mật khẩu trên port 26257. + + +## Định dạng URL kết nối + +Bạn có thể import kết nối bằng URL CockroachDB. + +Xem [Tham khảo URL kết nối](/vi/databases/connection-urls#cockroachdb) để biết định dạng URL đầy đủ. + +## Cấu hình mẫu + +### Phát triển cục bộ (Single Node) + +``` +Name: Local CockroachDB +Host: localhost +Port: 26257 +Username: root +Password: (để trống cho chế độ insecure) +Database: defaultdb +``` + +### CockroachDB Cloud (Serverless) + +``` +Name: CockroachDB Cloud +Host: free-tier.gcp-us-central1.cockroachlabs.cloud +Port: 26257 +Username: your_user +Password: (mật khẩu của bạn) +Database: defaultdb +``` + +### Docker Container + +``` +Name: Docker CockroachDB +Host: localhost +Port: 26257 (hoặc port đã map) +Username: root +Password: (để trống cho chế độ insecure) +Database: defaultdb +``` + + +CockroachDB Cloud yêu cầu SSL/TLS. Tải chứng chỉ CA của cluster từ CockroachDB Cloud Console và cấu hình trong phần SSL/TLS. + + +## Tính năng + +### Hỗ trợ Schema + +CockroachDB tổ chức các bảng theo schema, tuân theo quy ước PostgreSQL. TablePro hiển thị tất cả các schema mà người dùng có quyền truy cập và các bảng trong đó. + +### Chuyển đổi Schema + +Chuyển đổi giữa các schema bằng database switcher (**Cmd+K**): + +1. Nhấn **Cmd+K** để mở switcher +2. Chọn schema mục tiêu +3. Sidebar, truy vấn và toolbar sẽ cập nhật theo schema đã chọn + +### Duyệt bảng và xem dữ liệu + +Duyệt các bảng, view và sequence. Data grid hỗ trợ phân trang và chỉnh sửa trực tiếp. Thông tin primary key và index được hiển thị trong tab Structure. + +### Thực thi truy vấn + +Thực thi truy vấn với cú pháp SQL CockroachDB. Trình soạn thảo SQL cung cấp tô sáng cú pháp tương thích PostgreSQL. + +```sql +-- Lệnh riêng CockroachDB: SHOW RANGES +SHOW RANGES FROM TABLE users; + +-- Cột serial sử dụng unique_rowid() +CREATE TABLE events ( + id INT DEFAULT unique_rowid() PRIMARY KEY, + name STRING NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() +); + +-- Import dữ liệu +IMPORT INTO users CSV DATA ('nodelocal://1/users.csv'); +``` + +### Xuất dữ liệu + +Xuất kết quả truy vấn hoặc dữ liệu bảng ở nhiều định dạng: + +- CSV +- JSON +- SQL (câu lệnh INSERT) +- XLSX + +### Nhập dữ liệu + +Nhập dữ liệu từ các file CSV, JSON, SQL và XLSX vào bảng CockroachDB. + +### Tạo DDL + +Xem câu lệnh CREATE TABLE cho bất kỳ bảng nào, bao gồm các mệnh đề riêng của CockroachDB. + +## SSL/TLS + +Kết nối CockroachDB hỗ trợ mã hóa SSL/TLS, sử dụng cùng cấu hình như PostgreSQL: + +| Chế độ SSL | Mô tả | +|------------|-------| +| **Disabled** | Không mã hóa SSL | +| **Preferred** | Sử dụng SSL nếu có, chuyển sang không mã hóa nếu không | +| **Required** | Yêu cầu SSL, nhưng không xác minh chứng chỉ | +| **Verify CA** | Yêu cầu SSL và xác minh chứng chỉ server với CA | +| **Verify Identity** | Yêu cầu SSL, xác minh CA và xác minh hostname server | + + +CockroachDB Cloud (serverless và dedicated) yêu cầu SSL. Tải chứng chỉ CA từ Cloud Console và sử dụng chế độ **Verify CA**. + + +## Hỗ trợ SSH Tunnel + +Bạn có thể kết nối đến CockroachDB thông qua SSH tunnel để truy cập an toàn đến server từ xa. Xem [SSH Tunneling](/vi/databases/ssh-tunneling) để biết hướng dẫn chi tiết. + +## Khác biệt so với PostgreSQL + +CockroachDB tương thích PostgreSQL wire nhưng có một số khác biệt: + +| PostgreSQL | CockroachDB | +|------------|-------------| +| Port mặc định 5432 | Port mặc định 26257 | +| Kiểu `SERIAL` | `INT DEFAULT unique_rowid()` hoặc `UUID DEFAULT gen_random_uuid()` | +| `ENUM` qua `CREATE TYPE` | `CREATE TYPE ... AS ENUM` (hỗ trợ từ v20.2) | +| Đơn node mặc định | Phân tán mặc định | +| `pg_stat_activity` | `crdb_internal.node_sessions` | +| Triggers | Không hỗ trợ | +| Stored procedures | Hỗ trợ PL/pgSQL giới hạn (từ v23.1) | + +## Khắc phục sự cố + +### Kết nối bị từ chối + +**Triệu chứng**: "Connection refused" hoặc timeout + +**Nguyên nhân thường gặp**: + +1. **CockroachDB không chạy**: Khởi động node bằng `cockroach start-single-node` hoặc `cockroach demo` +2. **Sai port**: Xác nhận port SQL (mặc định 26257, không phải port HTTP admin 8080) +3. **Quy tắc firewall**: Đảm bảo port 26257 được mở cho IP client + +### Xác thực thất bại + +**Triệu chứng**: "password authentication failed" + +**Giải pháp**: + +1. Cho chế độ insecure, đảm bảo CockroachDB được khởi động với `--insecure` +2. Cho chế độ secure, xác nhận username và password +3. Kiểm tra quyền truy cập: `SHOW GRANTS ON DATABASE defaultdb` + +### Lỗi chứng chỉ SSL + +**Triệu chứng**: "certificate verify failed" hoặc lỗi SSL handshake + +**Giải pháp**: + +1. Tải đúng chứng chỉ CA từ CockroachDB Cloud Console +2. Cho cluster tự host, sử dụng cert CA được tạo bởi `cockroach cert create-ca` +3. Đặt chế độ SSL thành **Verify CA** và trỏ đến file chứng chỉ CA + +## Bước tiếp theo + + + + Kết nối an toàn đến các cluster CockroachDB từ xa + + + Viết và thực thi truy vấn CockroachDB + + + Nhập và xuất dữ liệu CockroachDB + + + Duyệt và chỉnh sửa dữ liệu trong data grid + + From 004944154332e182ef0318e75ee61af88f2f3329 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 8 Mar 2026 03:22:06 +0700 Subject: [PATCH 06/10] =?UTF-8?q?fix:=20resolve=20Oracle=20runtime=20issue?= =?UTF-8?q?s=20=E2=80=94=20empty=20tables,=20LIMIT=20syntax,=20NUMBER=20de?= =?UTF-8?q?code,=20pagination,=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/Database/FilterSQLGenerator.swift | 17 +- TablePro/Core/Database/OracleConnection.swift | 485 +++++------------- TablePro/Core/Database/OracleDriver.swift | 59 ++- .../Core/Services/ExportService+SQL.swift | 10 +- TablePro/Views/Export/ExportDialog.swift | 22 +- .../MainContentCoordinator+Navigation.swift | 29 +- .../Views/Main/MainContentCoordinator.swift | 94 +++- .../ForeignKeyPopoverContentView.swift | 11 +- 8 files changed, 300 insertions(+), 427 deletions(-) diff --git a/TablePro/Core/Database/FilterSQLGenerator.swift b/TablePro/Core/Database/FilterSQLGenerator.swift index e2bea122..d785a7f7 100644 --- a/TablePro/Core/Database/FilterSQLGenerator.swift +++ b/TablePro/Core/Database/FilterSQLGenerator.swift @@ -117,7 +117,7 @@ struct FilterSQLGenerator { switch databaseType { case .mysql, .mariadb: return "" - case .postgresql, .redshift, .cockroachdb, .sqlite, .mongodb, .redis, .mssql, .oracle: + case .postgresql, .redshift, .sqlite, .mongodb, .redis, .mssql, .oracle: return " ESCAPE '\\'" } } @@ -140,7 +140,7 @@ struct FilterSQLGenerator { switch databaseType { case .mysql, .mariadb: return "\(column) REGEXP '\(escapedPattern)'" - case .postgresql, .redshift, .cockroachdb: + case .postgresql, .redshift: return "\(column) ~ '\(escapedPattern)'" case .sqlite, .mongodb, .redis, .mssql, .oracle: return "\(column) LIKE '%\(escapedPattern)%'" @@ -160,10 +160,10 @@ struct FilterSQLGenerator { // Check for boolean literals if trimmed.caseInsensitiveCompare("TRUE") == .orderedSame { - return databaseType == .postgresql || databaseType == .redshift || databaseType == .cockroachdb ? "TRUE" : "1" + return databaseType == .postgresql || databaseType == .redshift ? "TRUE" : "1" } if trimmed.caseInsensitiveCompare("FALSE") == .orderedSame { - return databaseType == .postgresql || databaseType == .redshift || databaseType == .cockroachdb ? "FALSE" : "0" + return databaseType == .postgresql || databaseType == .redshift ? "FALSE" : "0" } // Try to detect numeric values @@ -246,7 +246,14 @@ extension FilterSQLGenerator { sql += "\n\(whereClause)" } - sql += "\nLIMIT \(limit)" + switch databaseType { + case .oracle: + sql += "\nORDER BY 1 OFFSET 0 ROWS FETCH NEXT \(limit) ROWS ONLY" + case .mssql: + sql += "\nORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT \(limit) ROWS ONLY" + default: + sql += "\nLIMIT \(limit)" + } return sql } } diff --git a/TablePro/Core/Database/OracleConnection.swift b/TablePro/Core/Database/OracleConnection.swift index 6607bb76..2c734d8e 100644 --- a/TablePro/Core/Database/OracleConnection.swift +++ b/TablePro/Core/Database/OracleConnection.swift @@ -2,15 +2,17 @@ // OracleConnection.swift // TablePro // -// Swift wrapper around Oracle OCI C API. +// Pure Swift Oracle connection using OracleNIO. // Provides thread-safe, async-friendly Oracle Database connections. // -import COracle import Foundation +import Logging +import NIOCore +import OracleNIO import OSLog -private let logger = Logger(subsystem: "com.TablePro", category: "OracleConnection") +private let osLogger = Logger(subsystem: "com.TablePro", category: "OracleConnection") // MARK: - Error Types @@ -35,17 +37,9 @@ struct OracleQueryResult { // MARK: - Connection Class -final class OracleConnection: @unchecked Sendable { +final class OracleConnectionWrapper: @unchecked Sendable { // MARK: - Properties - private var envHandle: UnsafeMutablePointer? - private var errHandle: UnsafeMutablePointer? - private var svcHandle: UnsafeMutablePointer? - private var srvHandle: UnsafeMutablePointer? - private var sesHandle: UnsafeMutablePointer? - - private let queue: DispatchQueue - private let host: String private let port: Int private let user: String @@ -55,6 +49,8 @@ final class OracleConnection: @unchecked Sendable { private let lock = NSLock() private var _isConnected = false + private var nioConnection: OracleNIO.OracleConnection? + private let nioLogger = Logging.Logger(label: "com.TablePro.oracle-nio") var isConnected: Bool { lock.lock() @@ -65,7 +61,6 @@ final class OracleConnection: @unchecked Sendable { // MARK: - Initialization init(host: String, port: Int, user: String, password: String, database: String, serviceName: String = "") { - self.queue = DispatchQueue(label: "com.TablePro.oracle.\(host).\(port)", qos: .userInitiated) self.host = host self.port = port self.user = user @@ -77,161 +72,37 @@ final class OracleConnection: @unchecked Sendable { // MARK: - Connection func connect() async throws { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - queue.async { [self] in - do { - try self.connectSync() - continuation.resume() - } catch { - continuation.resume(throwing: error) - } - } - } - } - - private func connectSync() throws { - var didConnect = false - defer { - if !didConnect { - if let ses = sesHandle { OCIHandleFree(ses, UInt32(OCI_HTYPE_SESSION)) } - if let svc = svcHandle { OCIHandleFree(svc, UInt32(OCI_HTYPE_SVCCTX)) } - if let srv = srvHandle { OCIHandleFree(srv, UInt32(OCI_HTYPE_SERVER)) } - if let err = errHandle { OCIHandleFree(err, UInt32(OCI_HTYPE_ERROR)) } - if let env = envHandle { OCIHandleFree(env, UInt32(OCI_HTYPE_ENV)) } - sesHandle = nil - svcHandle = nil - srvHandle = nil - errHandle = nil - envHandle = nil - } - } - - // Create OCI environment - var env: UnsafeMutableRawPointer? - var status = OCIEnvCreate( - &envHandle, UInt32(OCI_THREADED | OCI_OBJECT), - nil, nil, nil, nil, 0, nil - ) - guard status == Int32(OCI_SUCCESS), envHandle != nil else { - throw OracleError(message: "Failed to create OCI environment") - } - - // Allocate error handle - status = OCIHandleAlloc( - envHandle, &env, UInt32(OCI_HTYPE_ERROR), 0, nil - ) - guard status == Int32(OCI_SUCCESS) else { - throw OracleError(message: "Failed to allocate error handle") - } - errHandle = env?.assumingMemoryBound(to: OCIError.self) - - // Allocate server handle - env = nil - status = OCIHandleAlloc( - envHandle, &env, UInt32(OCI_HTYPE_SERVER), 0, nil - ) - guard status == Int32(OCI_SUCCESS) else { - throw OracleError(message: "Failed to allocate server handle") - } - srvHandle = env?.assumingMemoryBound(to: OCIServer.self) - - // Build connect string: //host:port/service_name let service = serviceName.isEmpty ? database : serviceName - let connectString = "//\(host):\(port)/\(service)" - - // Attach to server - status = connectString.withCString { cStr in - OCIServerAttach( - srvHandle, errHandle, - cStr, Int32(connectString.utf8.count), - UInt32(OCI_DEFAULT) - ) - } - guard status == Int32(OCI_SUCCESS) || status == Int32(OCI_SUCCESS_WITH_INFO) else { - let detail = getErrorMessage() - throw OracleError(message: "Failed to connect to \(host):\(port): \(detail)") - } - - // Allocate service context - env = nil - status = OCIHandleAlloc( - envHandle, &env, UInt32(OCI_HTYPE_SVCCTX), 0, nil - ) - guard status == Int32(OCI_SUCCESS) else { - throw OracleError(message: "Failed to allocate service context") - } - svcHandle = env?.assumingMemoryBound(to: OCISvcCtx.self) - - // Set server on service context - status = OCIAttrSet( - svcHandle, UInt32(OCI_HTYPE_SVCCTX), - srvHandle, 0, UInt32(OCI_ATTR_SERVER), - errHandle + let config = OracleNIO.OracleConnection.Configuration( + host: host, + port: port, + service: .serviceName(service), + username: user, + password: password ) - guard status == Int32(OCI_SUCCESS) else { - throw OracleError(message: "Failed to set server attribute") - } - // Allocate session handle - env = nil - status = OCIHandleAlloc( - envHandle, &env, UInt32(OCI_HTYPE_SESSION), 0, nil - ) - guard status == Int32(OCI_SUCCESS) else { - throw OracleError(message: "Failed to allocate session handle") - } - sesHandle = env?.assumingMemoryBound(to: OCISession.self) - - // Set username - status = user.withCString { cStr in - OCIAttrSet( - sesHandle, UInt32(OCI_HTYPE_SESSION), - UnsafeMutableRawPointer(mutating: cStr), UInt32(user.utf8.count), - UInt32(OCI_ATTR_USERNAME), errHandle + do { + let connection = try await OracleNIO.OracleConnection.connect( + configuration: config, + id: 1, + logger: nioLogger ) - } - guard status == Int32(OCI_SUCCESS) else { - throw OracleError(message: "Failed to set username") - } - // Set password - status = password.withCString { cStr in - OCIAttrSet( - sesHandle, UInt32(OCI_HTYPE_SESSION), - UnsafeMutableRawPointer(mutating: cStr), UInt32(password.utf8.count), - UInt32(OCI_ATTR_PASSWORD), errHandle - ) - } - guard status == Int32(OCI_SUCCESS) else { - throw OracleError(message: "Failed to set password") - } - - // Begin session - status = OCISessionBegin( - svcHandle, errHandle, sesHandle, - UInt32(OCI_CRED_RDBMS), UInt32(OCI_DEFAULT) - ) - guard status == Int32(OCI_SUCCESS) || status == Int32(OCI_SUCCESS_WITH_INFO) else { - let detail = getErrorMessage() - throw OracleError(message: "Authentication failed: \(detail)") - } + lock.lock() + nioConnection = connection + _isConnected = true + lock.unlock() - // Set session on service context - status = OCIAttrSet( - svcHandle, UInt32(OCI_HTYPE_SVCCTX), - sesHandle, 0, UInt32(OCI_ATTR_SESSION), - errHandle - ) - guard status == Int32(OCI_SUCCESS) else { - throw OracleError(message: "Failed to set session attribute") + 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)") } - - lock.lock() - _isConnected = true - lock.unlock() - - didConnect = true - logger.debug("Connected to Oracle \(self.host):\(self.port)/\(self.database)") } func disconnect() { @@ -241,234 +112,120 @@ final class OracleConnection: @unchecked Sendable { return } _isConnected = false + let connection = nioConnection + nioConnection = nil lock.unlock() - queue.sync { [self] in - if let ses = self.sesHandle { - OCISessionEnd(self.svcHandle, self.errHandle, ses, UInt32(OCI_DEFAULT)) - } - if let srv = self.srvHandle { - OCIServerDetach(srv, self.errHandle, UInt32(OCI_DEFAULT)) - } - if let ses = self.sesHandle { OCIHandleFree(ses, UInt32(OCI_HTYPE_SESSION)) } - if let svc = self.svcHandle { OCIHandleFree(svc, UInt32(OCI_HTYPE_SVCCTX)) } - if let srv = self.srvHandle { OCIHandleFree(srv, UInt32(OCI_HTYPE_SERVER)) } - if let err = self.errHandle { OCIHandleFree(err, UInt32(OCI_HTYPE_ERROR)) } - if let env = self.envHandle { OCIHandleFree(env, UInt32(OCI_HTYPE_ENV)) } - - self.sesHandle = nil - self.svcHandle = nil - self.srvHandle = nil - self.errHandle = nil - self.envHandle = nil + Task { + try? await connection?.close() + osLogger.debug("Disconnected from Oracle \(self.host):\(self.port)") } } // MARK: - Query Execution func executeQuery(_ query: String) async throws -> OracleQueryResult { - let queryToRun = String(query) - return try await withCheckedThrowingContinuation { [self] (cont: CheckedContinuation) in - queue.async { [self] in - do { - let result = try self.executeQuerySync(queryToRun) - cont.resume(returning: result) - } catch { - cont.resume(throwing: error) - } - } - } - } - - private func executeQuerySync(_ query: String) throws -> OracleQueryResult { - guard let svc = svcHandle, let err = errHandle, let env = envHandle else { + lock.lock() + guard let connection = nioConnection, _isConnected else { + lock.unlock() throw OracleError.notConnected } + lock.unlock() - // Allocate statement handle - var stmtRaw: UnsafeMutableRawPointer? - var status = OCIHandleAlloc(env, &stmtRaw, UInt32(OCI_HTYPE_STMT), 0, nil) - guard status == Int32(OCI_SUCCESS), let stmtPtr = stmtRaw else { - throw OracleError(message: "Failed to allocate statement handle") - } - let stmt = stmtPtr.assumingMemoryBound(to: OCIStmt.self) - defer { _ = OCIHandleFree(stmt, UInt32(OCI_HTYPE_STMT)) } - - // Prepare statement - status = query.withCString { cStr in - OCIStmtPrepare( - stmt, err, cStr, UInt32(query.utf8.count), - UInt32(OCI_DEFAULT), UInt32(OCI_DEFAULT) - ) - } - guard status == Int32(OCI_SUCCESS) else { - let detail = getErrorMessage() - throw OracleError(message: "Failed to prepare query: \(detail)") - } - - // Use OCI statement type detection instead of string matching - var stmtType: UInt16 = 0 - var stmtTypeSize: UInt32 = UInt32(MemoryLayout.size) - OCIAttrGet(stmt, UInt32(OCI_HTYPE_STMT), &stmtType, &stmtTypeSize, UInt32(OCI_ATTR_STMT_TYPE), err) - let isSelect = stmtType == UInt16(OCI_STMT_SELECT) - let iters: UInt32 = isSelect ? 0 : 1 - - // Execute - status = OCIStmtExecute( - svc, stmt, err, iters, 0, nil, nil, - UInt32(OCI_DEFAULT) - ) - guard status == Int32(OCI_SUCCESS) || status == Int32(OCI_SUCCESS_WITH_INFO) - || status == Int32(OCI_NO_DATA) else { - let detail = getErrorMessage() - throw OracleError(message: detail) - } - - // For non-SELECT, get affected row count - if !isSelect { - var rowCount: UInt32 = 0 - _ = OCIAttrGet( - stmt, UInt32(OCI_HTYPE_STMT), - &rowCount, nil, UInt32(OCI_ATTR_ROW_COUNT), err - ) - return OracleQueryResult(columns: [], columnTypeNames: [], rows: [], affectedRows: Int(rowCount)) - } - - // Get column count - var paramCount: UInt32 = 0 - _ = OCIAttrGet( - stmt, UInt32(OCI_HTYPE_STMT), - ¶mCount, nil, UInt32(OCI_ATTR_PARAM_COUNT), err - ) - - let numCols = Int(paramCount) - guard numCols > 0 else { - return OracleQueryResult(columns: [], columnTypeNames: [], rows: [], affectedRows: 0) - } + do { + let statement: OracleStatement = OracleStatement(stringLiteral: query) + let stream = try await connection.execute(statement, logger: nioLogger) - // Describe columns and set up define buffers - var columns: [String] = [] - var typeNames: [String] = [] - let bufSize = 4_096 - var buffers: [[CChar]] = [] - var indicators: [Int16] = Array(repeating: 0, count: numCols) - var returnLengths: [UInt16] = Array(repeating: 0, count: numCols) - var defines: [UnsafeMutablePointer?] = Array(repeating: nil, count: numCols) - - for i in 1...numCols { - // Get parameter descriptor - var paramRaw: UnsafeMutableRawPointer? - _ = OCIParamGet(stmt, UInt32(OCI_HTYPE_STMT), err, ¶mRaw, UInt32(i)) - - // Get column name - var namePtr: UnsafeMutablePointer? - var nameLen: UInt32 = 0 - _ = OCIAttrGet( - paramRaw, UInt32(OCI_DTYPE_PARAM), - &namePtr, &nameLen, UInt32(OCI_ATTR_NAME), err - ) - let colName: String - if let namePtr, nameLen > 0 { - colName = String(cString: namePtr) - } else { - colName = "col\(i)" + // Read column metadata from stream (available even with 0 rows) + var columns: [String] = [] + for col in stream.columns { + columns.append(col.name) } - columns.append(colName) + osLogger.debug("Oracle columns: \(columns.count) — \(columns.joined(separator: ", "))") - // Get data type - var dataType: UInt16 = 0 - _ = OCIAttrGet( - paramRaw, UInt32(OCI_DTYPE_PARAM), - &dataType, nil, UInt32(OCI_ATTR_DATA_TYPE), err - ) - typeNames.append(oracleTypeName(Int32(dataType))) + var columnTypeNames: [String] = [] + var allRows: [[String?]] = [] + var didReadTypes = false - // Define output buffer — convert everything to string - var buf = [CChar](repeating: 0, count: bufSize) - buffers.append(buf) - } + 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) + } - // Set up define by position for each column - for i in 0.. String? in - guard let base = bufPtr.baseAddress else { return nil } - return String(cString: base) - } - row.append(str) - } - } - allRows.append(row) + } 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))") } - - return OracleQueryResult( - columns: columns, - columnTypeNames: typeNames, - rows: allRows, - affectedRows: allRows.count - ) } // MARK: - Private Helpers - private func getErrorMessage() -> String { - guard let err = errHandle else { return "Unknown error" } - var errCode: Int32 = 0 - var buf = [CChar](repeating: 0, count: 512) - _ = OCIErrorGet( - err, 1, nil, &errCode, &buf, UInt32(buf.count), UInt32(OCI_HTYPE_ERROR) - ) - return String(cString: buf).trimmingCharacters(in: .whitespacesAndNewlines) + /// 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(_ type: Int32) -> String { - switch type { - case Int32(SQLT_CHR), Int32(SQLT_AFC), Int32(SQLT_AVC): return "varchar2" - case Int32(SQLT_NUM): return "number" - case Int32(SQLT_INT): return "integer" - case Int32(SQLT_FLT): return "float" - case Int32(SQLT_STR): return "string" - case Int32(SQLT_LNG): return "long" - case Int32(SQLT_RID), Int32(SQLT_RDD): return "rowid" - case Int32(SQLT_DAT): return "date" - case Int32(SQLT_BIN): return "raw" - case Int32(SQLT_LBI): return "long raw" - case Int32(SQLT_IBFLOAT): return "binary_float" - case Int32(SQLT_IBDOUBLE): return "binary_double" - case Int32(SQLT_CLOB): return "clob" - case Int32(SQLT_BLOB): return "blob" - case Int32(SQLT_BFILEE): return "bfile" - case Int32(SQLT_TIMESTAMP): return "timestamp" - case Int32(SQLT_TIMESTAMP_TZ): return "timestamp with time zone" - case Int32(SQLT_TIMESTAMP_LTZ): return "timestamp with local time zone" - case Int32(SQLT_INTERVAL_YM): return "interval year to month" - case Int32(SQLT_INTERVAL_DS): return "interval day to second" - default: return "unknown" - } + 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 index 05adf174..20ad5f49 100644 --- a/TablePro/Core/Database/OracleDriver.swift +++ b/TablePro/Core/Database/OracleDriver.swift @@ -14,7 +14,7 @@ final class OracleDriver: DatabaseDriver { let connection: DatabaseConnection private(set) var status: ConnectionStatus = .disconnected - private var oracleConn: OracleConnection? + private var oracleConn: OracleConnectionWrapper? private(set) var currentSchema: String = "" @@ -35,7 +35,7 @@ final class OracleDriver: DatabaseDriver { func connect() async throws { status = .connecting - let conn = OracleConnection( + let conn = OracleConnectionWrapper( host: connection.host, port: connection.port, user: connection.username, @@ -79,8 +79,35 @@ final class OracleDriver: DatabaseDriver { throw DatabaseError.connectionFailed("Not connected to Oracle") } let startTime = Date() - let result = try await conn.executeQuery(query) - return mapToQueryResult(result, executionTime: Date().timeIntervalSince(startTime)) + var result = try await conn.executeQuery(query) + 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 { @@ -311,7 +338,7 @@ final class OracleDriver: DatabaseDriver { 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" + let deleteRule = (row[safe: 4] ?? nil) ?? "NO ACTION" return ForeignKeyInfo( name: constraintName, column: columnName, @@ -426,7 +453,7 @@ final class OracleDriver: DatabaseDriver { let sql = """ SELECT (SELECT COUNT(*) FROM ALL_TABLES WHERE OWNER = '\(escapedDb)') AS table_count, - (SELECT NVL(SUM(BYTES), 0) FROM DBA_SEGMENTS WHERE OWNER = '\(escapedDb)') AS size_bytes + (SELECT NVL(SUM(BYTES), 0) FROM ALL_SEGMENTS WHERE OWNER = '\(escapedDb)') AS size_bytes FROM DUAL """ do { @@ -468,6 +495,26 @@ final class OracleDriver: DatabaseDriver { // MARK: - Private Helpers + private static let fromTableRegex = try? NSRegularExpression( + pattern: #"FROM\s+"([^"]+)""#, + 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 >= 2 else { + return nil + } + return ns.substring(with: match.range(at: 1)) + } + private func mapToQueryResult(_ oracleResult: OracleQueryResult, executionTime: TimeInterval) -> QueryResult { let columnTypes = oracleResult.columnTypeNames.map { rawType in ColumnType(fromSQLiteType: rawType) 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/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index 78688b4a..88247217 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -419,7 +419,7 @@ struct ExportDialog: View { var items: [ExportDatabaseItem] = [] switch connection.type { - case .postgresql, .redshift, .cockroachdb: + case .postgresql, .redshift: // PostgreSQL: fetch schemas within current database (can't query across databases) let schemas = try await fetchPostgreSQLSchemas(driver: driver) for schema in schemas { @@ -613,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 79b6a255..c01ce0e3 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -170,27 +170,6 @@ extension MainContentCoordinator { WHERE schema = '\(schema)' ORDER BY "table" """ - case .cockroachdb: - let schema: String - if let crdbDriver = DatabaseManager.shared.driver(for: connectionId) as? CockroachDBDriver { - schema = crdbDriver.escapedSchema - } else { - schema = "public" - } - sql = """ - SELECT - schemaname as schema, - relname as name, - 'TABLE' as kind, - n_live_tup as estimated_rows, - pg_size_pretty(pg_total_relation_size(schemaname||'.'||relname)) as total_size, - pg_size_pretty(pg_relation_size(schemaname||'.'||relname)) as data_size, - pg_size_pretty(pg_indexes_size(schemaname||'.'||relname)) as index_size, - obj_description((schemaname||'.'||relname)::regclass) as comment - FROM pg_stat_user_tables - WHERE schemaname = '\(schema)' - ORDER BY relname - """ case .mysql, .mariadb: sql = """ SELECT @@ -358,12 +337,10 @@ extension MainContentCoordinator { await loadSchema() NotificationCenter.default.post(name: .refreshData, object: nil) - } else if connection.type == .redshift || connection.type == .cockroachdb { - // Redshift/CockroachDB: switch schema + } else if connection.type == .redshift { + // Redshift: switch schema if let rsDriver = driver as? RedshiftDriver { try await rsDriver.switchSchema(to: database) - } else if let crdbDriver = driver as? CockroachDBDriver { - try await crdbDriver.switchSchema(to: database) } else { return } @@ -371,8 +348,6 @@ extension MainContentCoordinator { // Also switch metadata driver's schema if let rsMeta = DatabaseManager.shared.metadataDriver(for: connectionId) as? RedshiftDriver { try? await rsMeta.switchSchema(to: database) - } else if let crdbMeta = DatabaseManager.shared.metadataDriver(for: connectionId) as? CockroachDBDriver { - try? await crdbMeta.switchSchema(to: database) } // Update session diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 18193b52..04709801 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -303,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: [] ) @@ -446,7 +446,7 @@ final class MainContentCoordinator { return case .sqlite: explainSQL = "EXPLAIN QUERY PLAN \(stmt)" - case .mysql, .mariadb, .postgresql, .redshift, .cockroachdb: + case .mysql, .mariadb, .postgresql, .redshift: explainSQL = "EXPLAIN \(stmt)" case .mongodb: explainSQL = Self.buildMongoExplain(for: stmt) @@ -485,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 } @@ -598,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 } @@ -675,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 @@ -705,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]) @@ -1324,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/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 { From 288d061c0c766492474ae4c9e1bf8f9c8155722e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 8 Mar 2026 03:39:09 +0700 Subject: [PATCH 07/10] =?UTF-8?q?fix:=20resolve=20Oracle=20integration=20i?= =?UTF-8?q?ssues=20=E2=80=94=20health=20ping,=20pagination,=20URL=20round-?= =?UTF-8?q?trip,=20type=20mapping,=20FK=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 3 +- TablePro/AppDelegate.swift | 5 +- TablePro/Core/Database/OracleConnection.swift | 9 +- TablePro/Core/Database/OracleDriver.swift | 28 ++- TablePro/Core/Services/ColumnType.swift | 41 ++++ .../Core/Services/TableQueryBuilder.swift | 175 +++++++++++++++++- .../Utilities/ConnectionURLFormatter.swift | 11 +- .../Core/Utilities/ConnectionURLParser.swift | 31 +++- TablePro/Models/DatabaseConnection.swift | 16 +- .../Views/Sidebar/TableOperationDialog.swift | 9 +- TableProTests/Models/DatabaseTypeTests.swift | 7 +- docs/databases/overview.mdx | 11 +- 12 files changed, 298 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0702fbf8..f917a931 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Oracle Database support via OCI (Oracle Call Interface) -- CockroachDB database support (PostgreSQL wire-compatible via LibPQ) -- 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) +- 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 diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index ad8d608a..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", "cockroachdb", "oracle" + "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 ) } diff --git a/TablePro/Core/Database/OracleConnection.swift b/TablePro/Core/Database/OracleConnection.swift index 2c734d8e..1fc1cedd 100644 --- a/TablePro/Core/Database/OracleConnection.swift +++ b/TablePro/Core/Database/OracleConnection.swift @@ -40,6 +40,8 @@ struct OracleQueryResult { final class OracleConnectionWrapper: @unchecked Sendable { // MARK: - Properties + private static let connectionCounter = OSAllocatedUnfairLock(initialState: 0) + private let host: String private let port: Int private let user: String @@ -81,10 +83,15 @@ final class OracleConnectionWrapper: @unchecked Sendable { password: password ) + let connectionId = Self.connectionCounter.withLock { state -> Int in + state += 1 + return state + } + do { let connection = try await OracleNIO.OracleConnection.connect( configuration: config, - id: 1, + id: connectionId, logger: nioLogger ) diff --git a/TablePro/Core/Database/OracleDriver.swift b/TablePro/Core/Database/OracleDriver.swift index 20ad5f49..8588b3a7 100644 --- a/TablePro/Core/Database/OracleDriver.swift +++ b/TablePro/Core/Database/OracleDriver.swift @@ -19,7 +19,7 @@ final class OracleDriver: DatabaseDriver { private(set) var currentSchema: String = "" var escapedSchema: String { - currentSchema.replacingOccurrences(of: "'", with: "''") + SQLEscaping.escapeStringLiteral(currentSchema, databaseType: .oracle) } var serverVersion: String? { @@ -79,7 +79,14 @@ final class OracleDriver: DatabaseDriver { throw DatabaseError.connectionFailed("Not connected to Oracle") } let startTime = Date() - var result = try await conn.executeQuery(query) + + // 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. @@ -496,7 +503,7 @@ final class OracleDriver: DatabaseDriver { // MARK: - Private Helpers private static let fromTableRegex = try? NSRegularExpression( - pattern: #"FROM\s+"([^"]+)""#, + pattern: #"FROM\s+(?:"([^"]+)"|(\w+))"#, options: .caseInsensitive ) @@ -509,15 +516,24 @@ final class OracleDriver: DatabaseDriver { guard let match = fromTableRegex?.firstMatch( in: trimmed, range: NSRange(location: 0, length: ns.length) - ), match.numberOfRanges >= 2 else { + ), match.numberOfRanges >= 3 else { return nil } - return ns.substring(with: match.range(at: 1)) + // 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(fromSQLiteType: rawType) + ColumnType(fromOracleType: rawType) } return QueryResult( columns: oracleResult.columns, 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/TableQueryBuilder.swift b/TablePro/Core/Services/TableQueryBuilder.swift index dcd4bb52..dafbc108 100644 --- a/TablePro/Core/Services/TableQueryBuilder.swift +++ b/TablePro/Core/Services/TableQueryBuilder.swift @@ -196,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)" @@ -316,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)" @@ -381,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 { switch databaseType { - case .postgresql, .redshift, .cockroachdb: + case .postgresql, .redshift: return "\(column)::TEXT LIKE '%\(searchText)%' ESCAPE '\\'" case .mysql, .mariadb: return "CAST(\(column) AS CHAR) LIKE '%\(searchText)%'" @@ -702,6 +747,67 @@ struct TableQueryBuilder { return query } + private func buildMSSQLQuickSearchQuery( + 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 (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( @@ -740,4 +846,65 @@ struct TableQueryBuilder { 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/Utilities/ConnectionURLFormatter.swift b/TablePro/Core/Utilities/ConnectionURLFormatter.swift index 24cd3aa5..6d8a95d6 100644 --- a/TablePro/Core/Utilities/ConnectionURLFormatter.swift +++ b/TablePro/Core/Utilities/ConnectionURLFormatter.swift @@ -28,7 +28,6 @@ struct ConnectionURLFormatter { case .mariadb: return "mariadb" case .postgresql: return "postgresql" case .redshift: return "redshift" - case .cockroachdb: return "cockroachdb" case .sqlite: return "sqlite" case .mongodb: return "mongodb" case .redis: return "redis" @@ -75,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 { @@ -105,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 efa58188..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)" @@ -102,8 +104,6 @@ struct ConnectionURLParser { dbType = .mongodb case "redis", "rediss": dbType = .redis - case "cockroachdb": - dbType = .cockroachdb case "sqlserver", "mssql", "jdbc:sqlserver": dbType = .mssql case "oracle", "jdbc:oracle:thin": @@ -139,7 +139,8 @@ struct ConnectionURLParser { filterColumn: nil, filterOperation: nil, filterValue: nil, - filterCondition: nil + filterCondition: nil, + oracleServiceName: nil )) } @@ -185,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, @@ -210,7 +218,8 @@ struct ConnectionURLParser { filterColumn: ext.filterColumn, filterOperation: ext.filterOperation, filterValue: ext.filterValue, - filterCondition: ext.filterCondition + filterCondition: ext.filterCondition, + oracleServiceName: oracleServiceName )) } @@ -308,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, @@ -333,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/Models/DatabaseConnection.swift b/TablePro/Models/DatabaseConnection.swift index 6d7d7f30..be9b0926 100644 --- a/TablePro/Models/DatabaseConnection.swift +++ b/TablePro/Models/DatabaseConnection.swift @@ -109,7 +109,6 @@ enum DatabaseType: String, CaseIterable, Identifiable, Codable { case postgresql = "PostgreSQL" case sqlite = "SQLite" case redshift = "Redshift" - case cockroachdb = "CockroachDB" case mongodb = "MongoDB" case redis = "Redis" case mssql = "SQL Server" @@ -130,8 +129,6 @@ enum DatabaseType: String, CaseIterable, Identifiable, Codable { return "sqlite-icon" case .redshift: return "redshift-icon" - case .cockroachdb: - return "cockroachdb-icon" case .mongodb: return "mongodb-icon" case .redis: @@ -150,7 +147,6 @@ enum DatabaseType: String, CaseIterable, Identifiable, Codable { case .postgresql: return 5_432 case .sqlite: return 0 case .redshift: return 5_439 - case .cockroachdb: return 26_257 case .mongodb: return 27_017 case .redis: return 6_379 case .mssql: return 1_433 @@ -163,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, .cockroachdb, .mssql, .oracle: return true + case .mysql, .mariadb, .postgresql, .redshift, .mssql, .oracle: return true case .sqlite, .mongodb, .redis: return false } } @@ -171,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, .cockroachdb, .mssql, .oracle: + case .mysql, .mariadb, .postgresql, .sqlite, .redshift, .mssql, .oracle: return true case .mongodb, .redis: return false @@ -181,9 +177,9 @@ enum DatabaseType: String, CaseIterable, Identifiable, Codable { var beginTransactionSQL: String { switch self { case .mysql, .mariadb: return "START TRANSACTION" - case .postgresql, .redshift, .cockroachdb, .sqlite: return "BEGIN" + case .postgresql, .redshift, .sqlite: return "BEGIN" case .mssql: return "BEGIN TRANSACTION" - case .oracle: return "SET TRANSACTION READ WRITE" + case .oracle: return "" case .mongodb, .redis: return "" } } @@ -191,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, .cockroachdb, .mssql, .oracle: + case .mysql, .mariadb, .postgresql, .sqlite, .mssql, .oracle: return true case .redshift, .mongodb, .redis: return false @@ -204,7 +200,7 @@ enum DatabaseType: String, CaseIterable, Identifiable, Codable { switch self { case .mysql, .mariadb, .sqlite: return "`" - case .postgresql, .redshift, .cockroachdb, .mongodb, .redis, .oracle: + case .postgresql, .redshift, .mongodb, .redis, .oracle: return "\"" case .mssql: return "[" diff --git a/TablePro/Views/Sidebar/TableOperationDialog.swift b/TablePro/Views/Sidebar/TableOperationDialog.swift index 93c04085..b1b1d763 100644 --- a/TablePro/Views/Sidebar/TableOperationDialog.swift +++ b/TablePro/Views/Sidebar/TableOperationDialog.swift @@ -46,7 +46,7 @@ struct TableOperationDialog: View { // PostgreSQL supports CASCADE for both DROP and TRUNCATE. // MySQL, MariaDB, and SQLite do not support CASCADE for these operations. switch databaseType { - case .postgresql, .redshift, .cockroachdb: + case .postgresql, .redshift: return true default: return false @@ -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 == .cockroachdb + databaseType == .postgresql || databaseType == .redshift || databaseType == .oracle } private var ignoreFKDescription: String? { - if databaseType == .postgresql || databaseType == .redshift || databaseType == .cockroachdb { + 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/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/docs/databases/overview.mdx b/docs/databases/overview.mdx index e2b7e6d2..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 nine database systems: +TablePro supports nine database systems natively: @@ -102,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`, `redshift`, `cockroachdb`, and `oracle` 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:** @@ -567,9 +567,6 @@ TablePro auto-fills the port when you select a database type: Redshift data warehouse connections - - CockroachDB distributed SQL connections - Secure connections through SSH From 9dd632861d6e65828c65d13b4ea13ad046f45392 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 8 Mar 2026 03:42:19 +0700 Subject: [PATCH 08/10] fix: remove CockroachDB references from docs.json navigation --- docs/docs.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/docs.json b/docs/docs.json index 69a017a5..42a85474 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -39,7 +39,6 @@ "databases/mongodb", "databases/redis", "databases/redshift", - "databases/cockroachdb", "databases/mssql", "databases/oracle", "databases/ssh-tunneling" @@ -129,7 +128,6 @@ "vi/databases/mongodb", "vi/databases/redis", "vi/databases/redshift", - "vi/databases/cockroachdb", "vi/databases/mssql", "vi/databases/oracle", "vi/databases/ssh-tunneling" From 80a3b6e64f58ac2c9115b4b3a555f9c5a42e9df0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 8 Mar 2026 03:45:01 +0700 Subject: [PATCH 09/10] fix: remove CockroachDB doc files from Oracle branch --- docs/databases/cockroachdb.mdx | 224 ------------------------------ docs/vi/databases/cockroachdb.mdx | 224 ------------------------------ 2 files changed, 448 deletions(-) delete mode 100644 docs/databases/cockroachdb.mdx delete mode 100644 docs/vi/databases/cockroachdb.mdx diff --git a/docs/databases/cockroachdb.mdx b/docs/databases/cockroachdb.mdx deleted file mode 100644 index 88378019..00000000 --- a/docs/databases/cockroachdb.mdx +++ /dev/null @@ -1,224 +0,0 @@ ---- -title: CockroachDB -description: Connect to CockroachDB databases with TablePro ---- - -# CockroachDB Connections - -TablePro supports CockroachDB, a distributed SQL database built on PostgreSQL wire protocol. Connections work through the same libpq driver used for PostgreSQL, with CockroachDB-specific metadata queries. - -## Quick Setup - - - - Click **New Connection** from the Welcome screen or **File** > **New Connection** - - - Choose **CockroachDB** from the database type selector - - - Fill in host, port, username, password, and database name - - - Click **Test Connection**, then **Create** - - - -## Connection Settings - -### Required Fields - -| Field | Description | Default | -|-------|-------------|---------| -| **Name** | Connection identifier | - | -| **Host** | Server hostname or IP | `localhost` | -| **Port** | CockroachDB SQL port | `26257` | -| **Username** | Database user | `root` | -| **Password** | User password | - | -| **Database** | Database name | `defaultdb` | - - -Local CockroachDB instances started with `cockroach demo` or `cockroach start-single-node --insecure` accept connections from `root` without a password on port 26257. - - -## Connection URL Format - -You can import connections using a CockroachDB URL. - -See [Connection URL Reference](/databases/connection-urls#cockroachdb) for the full URL format. - -## Example Configurations - -### Local Development (Single Node) - -``` -Name: Local CockroachDB -Host: localhost -Port: 26257 -Username: root -Password: (empty for insecure mode) -Database: defaultdb -``` - -### CockroachDB Cloud (Serverless) - -``` -Name: CockroachDB Cloud -Host: free-tier.gcp-us-central1.cockroachlabs.cloud -Port: 26257 -Username: your_user -Password: (your password) -Database: defaultdb -``` - -### Docker Container - -``` -Name: Docker CockroachDB -Host: localhost -Port: 26257 (or your mapped port) -Username: root -Password: (empty for insecure mode) -Database: defaultdb -``` - - -CockroachDB Cloud requires SSL/TLS. Download your cluster's CA certificate from the CockroachDB Cloud Console and configure it in the SSL/TLS section. - - -## Features - -### Schema Support - -CockroachDB organizes tables into schemas, following PostgreSQL conventions. TablePro displays all schemas accessible to your user and their tables. - -### Schema Switching - -Switch between schemas using the database switcher (**Cmd+K**): - -1. Press **Cmd+K** to open the switcher -2. Select the target schema -3. The sidebar, queries, and toolbar update to reflect the selected schema - -### Table Browsing and Data Viewing - -Browse tables, views, and sequences. The data grid supports pagination and inline editing. Primary key and index information is displayed in the Structure tab. - -### Query Execution - -Execute queries with CockroachDB SQL syntax. The SQL editor provides PostgreSQL-compatible syntax highlighting. - -```sql --- CockroachDB-specific: SHOW RANGES -SHOW RANGES FROM TABLE users; - --- Serial columns use unique_rowid() -CREATE TABLE events ( - id INT DEFAULT unique_rowid() PRIMARY KEY, - name STRING NOT NULL, - created_at TIMESTAMPTZ DEFAULT now() -); - --- Import data -IMPORT INTO users CSV DATA ('nodelocal://1/users.csv'); -``` - -### Data Export - -Export query results or table data in multiple formats: - -- CSV -- JSON -- SQL (INSERT statements) -- XLSX - -### Data Import - -Import data from CSV, JSON, SQL, and XLSX files into CockroachDB tables. - -### DDL Generation - -View the CREATE TABLE statement for any table, including CockroachDB-specific clauses. - -## SSL/TLS - -CockroachDB connections support SSL/TLS encryption, using the same configuration as PostgreSQL: - -| SSL Mode | Description | -|----------|-------------| -| **Disabled** | No SSL encryption | -| **Preferred** | Use SSL if available, fall back to unencrypted | -| **Required** | Require SSL, but don't verify certificates | -| **Verify CA** | Require SSL and verify the server certificate against a CA | -| **Verify Identity** | Require SSL, verify CA, and verify the server hostname | - - -CockroachDB Cloud (serverless and dedicated) requires SSL. Download the CA certificate from the Cloud Console and use **Verify CA** mode. - - -## SSH Tunnel Support - -You can connect to CockroachDB through an SSH tunnel for secure access to remote servers. See [SSH Tunneling](/databases/ssh-tunneling) for detailed setup instructions. - -## Differences from PostgreSQL - -CockroachDB is PostgreSQL wire-compatible but has some differences: - -| PostgreSQL | CockroachDB | -|------------|-------------| -| Default port 5432 | Default port 26257 | -| `SERIAL` type | `INT DEFAULT unique_rowid()` or `UUID DEFAULT gen_random_uuid()` | -| `ENUM` via `CREATE TYPE` | `CREATE TYPE ... AS ENUM` (supported since v20.2) | -| Single-node by default | Distributed by default | -| `pg_stat_activity` | `crdb_internal.node_sessions` | -| Triggers | Not supported | -| Stored procedures | Limited PL/pgSQL support (since v23.1) | - -## Troubleshooting - -### Connection Refused - -**Symptoms**: "Connection refused" or timeout - -**Common causes**: - -1. **CockroachDB not running**: Start the node with `cockroach start-single-node` or `cockroach demo` -2. **Wrong port**: Verify the SQL port (default 26257, not the HTTP admin port 8080) -3. **Firewall rules**: Ensure port 26257 is open for your client IP - -### Authentication Failed - -**Symptoms**: "password authentication failed" - -**Solutions**: - -1. For insecure mode, ensure you started CockroachDB with `--insecure` -2. For secure mode, verify your username and password -3. Check that the user has been granted access: `SHOW GRANTS ON DATABASE defaultdb` - -### SSL Certificate Errors - -**Symptoms**: "certificate verify failed" or SSL handshake errors - -**Solutions**: - -1. Download the correct CA certificate from the CockroachDB Cloud Console -2. For self-hosted clusters, use the CA cert generated during `cockroach cert create-ca` -3. Set SSL mode to **Verify CA** and point to the CA certificate file - -## Next Steps - - - - Connect securely to remote CockroachDB clusters - - - Write and execute CockroachDB queries - - - Import and export CockroachDB data - - - Browse and edit data in the data grid - - diff --git a/docs/vi/databases/cockroachdb.mdx b/docs/vi/databases/cockroachdb.mdx deleted file mode 100644 index 71fb5878..00000000 --- a/docs/vi/databases/cockroachdb.mdx +++ /dev/null @@ -1,224 +0,0 @@ ---- -title: CockroachDB -description: Kết nối đến cơ sở dữ liệu CockroachDB với TablePro ---- - -# Kết nối CockroachDB - -TablePro hỗ trợ CockroachDB, cơ sở dữ liệu SQL phân tán dựa trên giao thức PostgreSQL wire. Các kết nối hoạt động qua cùng driver libpq được sử dụng cho PostgreSQL, với các truy vấn metadata riêng cho CockroachDB. - -## Thiết lập nhanh - - - - Click **New Connection** từ màn hình Welcome hoặc **File** > **New Connection** - - - Chọn **CockroachDB** từ danh sách loại cơ sở dữ liệu - - - Điền host, port, username, password và tên database - - - 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 IP của server | `localhost` | -| **Port** | Port SQL của CockroachDB | `26257` | -| **Username** | Tên người dùng | `root` | -| **Password** | Mật khẩu | - | -| **Database** | Tên database | `defaultdb` | - - -Các instance CockroachDB cục bộ khởi động bằng `cockroach demo` hoặc `cockroach start-single-node --insecure` chấp nhận kết nối từ `root` không cần mật khẩu trên port 26257. - - -## Định dạng URL kết nối - -Bạn có thể import kết nối bằng URL CockroachDB. - -Xem [Tham khảo URL kết nối](/vi/databases/connection-urls#cockroachdb) để biết định dạng URL đầy đủ. - -## Cấu hình mẫu - -### Phát triển cục bộ (Single Node) - -``` -Name: Local CockroachDB -Host: localhost -Port: 26257 -Username: root -Password: (để trống cho chế độ insecure) -Database: defaultdb -``` - -### CockroachDB Cloud (Serverless) - -``` -Name: CockroachDB Cloud -Host: free-tier.gcp-us-central1.cockroachlabs.cloud -Port: 26257 -Username: your_user -Password: (mật khẩu của bạn) -Database: defaultdb -``` - -### Docker Container - -``` -Name: Docker CockroachDB -Host: localhost -Port: 26257 (hoặc port đã map) -Username: root -Password: (để trống cho chế độ insecure) -Database: defaultdb -``` - - -CockroachDB Cloud yêu cầu SSL/TLS. Tải chứng chỉ CA của cluster từ CockroachDB Cloud Console và cấu hình trong phần SSL/TLS. - - -## Tính năng - -### Hỗ trợ Schema - -CockroachDB tổ chức các bảng theo schema, tuân theo quy ước PostgreSQL. TablePro hiển thị tất cả các schema mà người dùng có quyền truy cập và các bảng trong đó. - -### Chuyển đổi Schema - -Chuyển đổi giữa các schema bằng database switcher (**Cmd+K**): - -1. Nhấn **Cmd+K** để mở switcher -2. Chọn schema mục tiêu -3. Sidebar, truy vấn và toolbar sẽ cập nhật theo schema đã chọn - -### Duyệt bảng và xem dữ liệu - -Duyệt các bảng, view và sequence. Data grid hỗ trợ phân trang và chỉnh sửa trực tiếp. Thông tin primary key và index được hiển thị trong tab Structure. - -### Thực thi truy vấn - -Thực thi truy vấn với cú pháp SQL CockroachDB. Trình soạn thảo SQL cung cấp tô sáng cú pháp tương thích PostgreSQL. - -```sql --- Lệnh riêng CockroachDB: SHOW RANGES -SHOW RANGES FROM TABLE users; - --- Cột serial sử dụng unique_rowid() -CREATE TABLE events ( - id INT DEFAULT unique_rowid() PRIMARY KEY, - name STRING NOT NULL, - created_at TIMESTAMPTZ DEFAULT now() -); - --- Import dữ liệu -IMPORT INTO users CSV DATA ('nodelocal://1/users.csv'); -``` - -### Xuất dữ liệu - -Xuất kết quả truy vấn hoặc dữ liệu bảng ở nhiều định dạng: - -- CSV -- JSON -- SQL (câu lệnh INSERT) -- XLSX - -### Nhập dữ liệu - -Nhập dữ liệu từ các file CSV, JSON, SQL và XLSX vào bảng CockroachDB. - -### Tạo DDL - -Xem câu lệnh CREATE TABLE cho bất kỳ bảng nào, bao gồm các mệnh đề riêng của CockroachDB. - -## SSL/TLS - -Kết nối CockroachDB hỗ trợ mã hóa SSL/TLS, sử dụng cùng cấu hình như PostgreSQL: - -| Chế độ SSL | Mô tả | -|------------|-------| -| **Disabled** | Không mã hóa SSL | -| **Preferred** | Sử dụng SSL nếu có, chuyển sang không mã hóa nếu không | -| **Required** | Yêu cầu SSL, nhưng không xác minh chứng chỉ | -| **Verify CA** | Yêu cầu SSL và xác minh chứng chỉ server với CA | -| **Verify Identity** | Yêu cầu SSL, xác minh CA và xác minh hostname server | - - -CockroachDB Cloud (serverless và dedicated) yêu cầu SSL. Tải chứng chỉ CA từ Cloud Console và sử dụng chế độ **Verify CA**. - - -## Hỗ trợ SSH Tunnel - -Bạn có thể kết nối đến CockroachDB thông qua SSH tunnel để truy cập an toàn đến server từ xa. Xem [SSH Tunneling](/vi/databases/ssh-tunneling) để biết hướng dẫn chi tiết. - -## Khác biệt so với PostgreSQL - -CockroachDB tương thích PostgreSQL wire nhưng có một số khác biệt: - -| PostgreSQL | CockroachDB | -|------------|-------------| -| Port mặc định 5432 | Port mặc định 26257 | -| Kiểu `SERIAL` | `INT DEFAULT unique_rowid()` hoặc `UUID DEFAULT gen_random_uuid()` | -| `ENUM` qua `CREATE TYPE` | `CREATE TYPE ... AS ENUM` (hỗ trợ từ v20.2) | -| Đơn node mặc định | Phân tán mặc định | -| `pg_stat_activity` | `crdb_internal.node_sessions` | -| Triggers | Không hỗ trợ | -| Stored procedures | Hỗ trợ PL/pgSQL giới hạn (từ v23.1) | - -## Khắc phục sự cố - -### Kết nối bị từ chối - -**Triệu chứng**: "Connection refused" hoặc timeout - -**Nguyên nhân thường gặp**: - -1. **CockroachDB không chạy**: Khởi động node bằng `cockroach start-single-node` hoặc `cockroach demo` -2. **Sai port**: Xác nhận port SQL (mặc định 26257, không phải port HTTP admin 8080) -3. **Quy tắc firewall**: Đảm bảo port 26257 được mở cho IP client - -### Xác thực thất bại - -**Triệu chứng**: "password authentication failed" - -**Giải pháp**: - -1. Cho chế độ insecure, đảm bảo CockroachDB được khởi động với `--insecure` -2. Cho chế độ secure, xác nhận username và password -3. Kiểm tra quyền truy cập: `SHOW GRANTS ON DATABASE defaultdb` - -### Lỗi chứng chỉ SSL - -**Triệu chứng**: "certificate verify failed" hoặc lỗi SSL handshake - -**Giải pháp**: - -1. Tải đúng chứng chỉ CA từ CockroachDB Cloud Console -2. Cho cluster tự host, sử dụng cert CA được tạo bởi `cockroach cert create-ca` -3. Đặt chế độ SSL thành **Verify CA** và trỏ đến file chứng chỉ CA - -## Bước tiếp theo - - - - Kết nối an toàn đến các cluster CockroachDB từ xa - - - Viết và thực thi truy vấn CockroachDB - - - Nhập và xuất dữ liệu CockroachDB - - - Duyệt và chỉnh sửa dữ liệu trong data grid - - From b193a7ca33b17f185dcf3bb05cb35aec2f02c7cc Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 8 Mar 2026 03:49:27 +0700 Subject: [PATCH 10/10] fix: remove all CockroachDB references from Oracle branch --- TablePro.xcodeproj/project.pbxproj | 17 ++ .../xcshareddata/swiftpm/Package.resolved | 128 ++++++++++- .../Autocomplete/SQLCompletionProvider.swift | 4 +- .../SQLStatementGenerator.swift | 6 +- TablePro/Core/Database/COracle/COracle.h | 9 - .../Core/Database/COracle/include/oci_stub.h | 199 ------------------ .../Core/Database/COracle/module.modulemap | 4 - TablePro/Core/Database/DatabaseDriver.swift | 4 +- TablePro/Core/Database/DatabaseManager.swift | 22 +- TablePro/Core/Database/SQLEscaping.swift | 2 +- .../SchemaStatementGenerator.swift | 14 +- TablePro/Core/Services/ImportService.swift | 4 +- .../Core/Services/SQLDialectProvider.swift | 2 +- .../Core/Utilities/SQLParameterInliner.swift | 2 +- TablePro/Resources/Localizable.xcstrings | 18 ++ TablePro/Theme/Theme.swift | 3 - .../DatabaseSwitcherViewModel.swift | 6 +- .../Views/Connection/ConnectionFormView.swift | 1 - ...inContentCoordinator+TableOperations.swift | 10 +- .../Main/MainContentCommandActions.swift | 4 +- .../Structure/TypePickerContentView.swift | 10 +- docs/databases/connection-urls.mdx | 1 - docs/vi/databases/connection-urls.mdx | 1 - docs/vi/databases/overview.mdx | 13 +- 24 files changed, 204 insertions(+), 280 deletions(-) delete mode 100644 TablePro/Core/Database/COracle/COracle.h delete mode 100644 TablePro/Core/Database/COracle/include/oci_stub.h delete mode 100644 TablePro/Core/Database/COracle/module.modulemap 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/Core/Autocomplete/SQLCompletionProvider.swift b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift index 51dd5c07..fcaa4340 100644 --- a/TablePro/Core/Autocomplete/SQLCompletionProvider.swift +++ b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift @@ -327,7 +327,7 @@ final class SQLCompletionProvider { "ENGINE", "CHARSET", "COLLATE", "COMMENT", "AUTO_INCREMENT", "ROW_FORMAT", "DEFAULT CHARSET", ]) - case .postgresql, .redshift, .cockroachdb: + case .postgresql, .redshift: items += filterKeywords([ "TABLESPACE", "INHERITS", "PARTITION BY", "WITH", "WITHOUT OIDS", @@ -491,7 +491,7 @@ final class SQLCompletionProvider { "BINARY", "VARBINARY", ] - case .postgresql, .redshift, .cockroachdb: + case .postgresql, .redshift: types += [ "BIGSERIAL", "SERIAL", "SMALLSERIAL", "DOUBLE PRECISION", "MONEY", diff --git a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift index 319a47ed..103b71ac 100644 --- a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift +++ b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift @@ -98,7 +98,7 @@ struct SQLStatementGenerator { /// Get placeholder syntax for the database type private func placeholder(at index: Int) -> String { switch databaseType { - case .postgresql, .redshift, .cockroachdb: + case .postgresql, .redshift: return "$\(index + 1)" // PostgreSQL uses $1, $2, etc. case .mysql, .mariadb, .sqlite, .mongodb, .redis, .mssql, .oracle: return "?" // MySQL, MariaDB, SQLite, MongoDB, MSSQL, and Oracle use ? @@ -277,7 +277,7 @@ struct SQLStatementGenerator { 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, .cockroachdb, .mongodb, .redis: + case .postgresql, .redshift, .mongodb, .redis: sql = "UPDATE \(databaseType.quoteIdentifier(tableName)) SET \(setClauses) WHERE \(whereClause)" } @@ -353,7 +353,7 @@ struct SQLStatementGenerator { 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, .cockroachdb, .mongodb, .redis: + case .postgresql, .redshift, .mongodb, .redis: sql = "DELETE FROM \(databaseType.quoteIdentifier(tableName)) WHERE \(whereClause)" } diff --git a/TablePro/Core/Database/COracle/COracle.h b/TablePro/Core/Database/COracle/COracle.h deleted file mode 100644 index f660206f..00000000 --- a/TablePro/Core/Database/COracle/COracle.h +++ /dev/null @@ -1,9 +0,0 @@ -// -// COracle.h -// TablePro -// -// Umbrella header for Oracle OCI C bridge. -// Requires Oracle Instant Client headers in include/. -// - -#include "include/oci_stub.h" diff --git a/TablePro/Core/Database/COracle/include/oci_stub.h b/TablePro/Core/Database/COracle/include/oci_stub.h deleted file mode 100644 index 847f7274..00000000 --- a/TablePro/Core/Database/COracle/include/oci_stub.h +++ /dev/null @@ -1,199 +0,0 @@ -// -// oci_stub.h - Oracle OCI stub header -// Swift-compatible bridge: real Oracle Instant Client provides the implementation. -// -#ifndef _OCI_STUB_H_ -#define _OCI_STUB_H_ - -#include - -// Basic OCI types -typedef int32_t sword; -typedef uint32_t ub4; -typedef uint16_t ub2; -typedef uint8_t ub1; -typedef int32_t sb4; -typedef int16_t sb2; -typedef int8_t sb1; -typedef char OraText; -typedef unsigned char oraub8_t; -typedef int64_t orasb8_t; - -// OCI Return codes -#define OCI_SUCCESS 0 -#define OCI_SUCCESS_WITH_INFO 1 -#define OCI_NO_DATA 100 -#define OCI_ERROR -1 -#define OCI_INVALID_HANDLE -2 -#define OCI_NEED_DATA 99 -#define OCI_STILL_EXECUTING -3123 - -// OCI Handle types -#define OCI_HTYPE_ENV 1 -#define OCI_HTYPE_ERROR 2 -#define OCI_HTYPE_SVCCTX 3 -#define OCI_HTYPE_STMT 4 -#define OCI_HTYPE_SERVER 8 -#define OCI_HTYPE_SESSION 9 -#define OCI_HTYPE_AUTHINFO 12 - -// OCI Descriptor types -#define OCI_DTYPE_PARAM 53 - -// OCI Attribute types -#define OCI_ATTR_SERVER 6 -#define OCI_ATTR_SESSION 7 -#define OCI_ATTR_USERNAME 22 -#define OCI_ATTR_PASSWORD 23 -#define OCI_ATTR_DATA_TYPE 24 -#define OCI_ATTR_DATA_SIZE 25 -#define OCI_ATTR_NAME 26 -#define OCI_ATTR_PRECISION 27 -#define OCI_ATTR_SCALE 28 -#define OCI_ATTR_IS_NULL 29 -#define OCI_ATTR_ROW_COUNT 30 -#define OCI_ATTR_NUM_COLS 31 -#define OCI_ATTR_PARAM_COUNT 32 - -// OCI Data types -#define SQLT_CHR 1 // VARCHAR2 -#define SQLT_NUM 2 // NUMBER -#define SQLT_INT 3 // INTEGER -#define SQLT_FLT 4 // FLOAT -#define SQLT_STR 5 // NULL-terminated STRING -#define SQLT_LNG 8 // LONG -#define SQLT_RID 11 // ROWID -#define SQLT_DAT 12 // DATE -#define SQLT_BIN 23 // RAW -#define SQLT_LBI 24 // LONG RAW -#define SQLT_AFC 96 // CHAR -#define SQLT_AVC 97 // CHARZ -#define SQLT_IBFLOAT 100 // Binary FLOAT (BINARY_FLOAT) -#define SQLT_IBDOUBLE 101 // Binary DOUBLE (BINARY_DOUBLE) -#define SQLT_RDD 104 // ROWID descriptor -#define SQLT_NTY 108 // Named type (Object type, VARRAY, nested table) -#define SQLT_CLOB 112 // CLOB -#define SQLT_BLOB 113 // BLOB -#define SQLT_BFILEE 114 // BFILE -#define SQLT_TIMESTAMP 187 // TIMESTAMP -#define SQLT_TIMESTAMP_TZ 188 // TIMESTAMP WITH TIME ZONE -#define SQLT_INTERVAL_YM 189 // INTERVAL YEAR TO MONTH -#define SQLT_INTERVAL_DS 190 // INTERVAL DAY TO SECOND -#define SQLT_TIMESTAMP_LTZ 232 // TIMESTAMP WITH LOCAL TIME ZONE - -// OCI Credentials -#define OCI_CRED_RDBMS 1 -#define OCI_CRED_EXT 2 - -// OCI Mode flags -#define OCI_DEFAULT 0x00000000 -#define OCI_THREADED 0x00000001 -#define OCI_OBJECT 0x00000002 -#define OCI_COMMIT_ON_SUCCESS 0x00000020 -#define OCI_DESCRIBE_ONLY 0x00000010 -#define OCI_STMT_SCROLLABLE_READONLY 0x00000008 - -// OCI Statement types -#define OCI_STMT_SELECT 1 -#define OCI_STMT_UPDATE 2 -#define OCI_STMT_DELETE 3 -#define OCI_STMT_INSERT 4 -#define OCI_STMT_CREATE 5 -#define OCI_STMT_DROP 6 -#define OCI_STMT_ALTER 7 -#define OCI_STMT_BEGIN 8 -#define OCI_STMT_DECLARE 9 - -// OCI Fetch orientation -#define OCI_FETCH_NEXT 2 - -// Opaque handle types — placeholder bodies for Swift UnsafeMutablePointer compatibility -struct OCIEnv { char _placeholder; }; -typedef struct OCIEnv OCIEnv; - -struct OCIError { char _placeholder; }; -typedef struct OCIError OCIError; - -struct OCISvcCtx { char _placeholder; }; -typedef struct OCISvcCtx OCISvcCtx; - -struct OCIStmt { char _placeholder; }; -typedef struct OCIStmt OCIStmt; - -struct OCIServer { char _placeholder; }; -typedef struct OCIServer OCIServer; - -struct OCISession { char _placeholder; }; -typedef struct OCISession OCISession; - -struct OCIDefine { char _placeholder; }; -typedef struct OCIDefine OCIDefine; - -struct OCIParam { char _placeholder; }; -typedef struct OCIParam OCIParam; - -struct OCIAuthInfo { char _placeholder; }; -typedef struct OCIAuthInfo OCIAuthInfo; - -// --- OCI Function Prototypes --- - -// Environment -sword OCIEnvCreate(OCIEnv **envhpp, ub4 mode, const void *ctxp, - const void *(*malfp)(void *, size_t), - const void *(*ralfp)(void *, void *, size_t), - void (*mfreefp)(void *, void *), - size_t xtramem_sz, void **usrmempp); - -// Handle allocation/free -sword OCIHandleAlloc(const void *parenth, void **hndlpp, ub4 type, - size_t xtramem_sz, void **usrmempp); -sword OCIHandleFree(void *hndlp, ub4 type); - -// Attribute get/set -sword OCIAttrGet(const void *trgthndlp, ub4 trghndltyp, - void *attributep, ub4 *sizep, ub4 attrtype, - OCIError *errhp); -sword OCIAttrSet(void *trgthndlp, ub4 trghndltyp, - void *attributep, ub4 size, ub4 attrtype, - OCIError *errhp); - -// Server attach/detach -sword OCIServerAttach(OCIServer *srvhp, OCIError *errhp, - const OraText *dblink, sb4 dblink_len, ub4 mode); -sword OCIServerDetach(OCIServer *srvhp, OCIError *errhp, ub4 mode); - -// Session begin/end -sword OCISessionBegin(OCISvcCtx *svchp, OCIError *errhp, - OCISession *usrhp, ub4 creession, ub4 mode); -sword OCISessionEnd(OCISvcCtx *svchp, OCIError *errhp, - OCISession *usrhp, ub4 mode); - -// Statement prepare/execute/fetch -sword OCIStmtPrepare(OCIStmt *stmtp, OCIError *errhp, - const OraText *stmt, ub4 stmt_len, - ub4 language, ub4 mode); -sword OCIStmtExecute(OCISvcCtx *svchp, OCIStmt *stmtp, OCIError *errhp, - ub4 iters, ub4 rowoff, const void *snap_in, - void *snap_out, ub4 mode); -sword OCIStmtFetch2(OCIStmt *stmtp, OCIError *errhp, ub4 nrows, - ub2 orientation, sb4 fetchOffset, ub4 mode); - -// Define by position (for SELECT result binding) -sword OCIDefineByPos(OCIStmt *stmtp, OCIDefine **defnpp, OCIError *errhp, - ub4 position, void *valuep, sb4 value_sz, - ub2 dty, void *indp, ub2 *rlenp, ub2 *rcodep, - ub4 mode); - -// Parameter descriptor -sword OCIParamGet(const void *hndlp, ub4 htype, OCIError *errhp, - void **parmdpp, ub4 pos); - -// Transaction -sword OCITransCommit(OCISvcCtx *svchp, OCIError *errhp, ub4 flags); -sword OCITransRollback(OCISvcCtx *svchp, OCIError *errhp, ub4 flags); - -// Error info -sword OCIErrorGet(void *hndlp, ub4 recordno, OraText *sqlstate, - sb4 *errcodep, OraText *bufp, ub4 bufsiz, ub4 type); - -#endif // _OCI_STUB_H_ diff --git a/TablePro/Core/Database/COracle/module.modulemap b/TablePro/Core/Database/COracle/module.modulemap deleted file mode 100644 index 3ea4e9cf..00000000 --- a/TablePro/Core/Database/COracle/module.modulemap +++ /dev/null @@ -1,4 +0,0 @@ -module COracle { - umbrella header "COracle.h" - export * -} diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 1ce783ae..2f85197e 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -265,7 +265,7 @@ extension DatabaseDriver { _ = try await execute(query: "SET SESSION max_execution_time = \(ms)") case .mariadb: _ = try await execute(query: "SET SESSION max_statement_time = \(seconds)") - case .postgresql, .redshift, .cockroachdb: + case .postgresql, .redshift: _ = try await execute(query: "SET statement_timeout = '\(ms)'") case .sqlite: break // SQLite busy_timeout handled by driver directly @@ -314,8 +314,6 @@ enum DatabaseDriverFactory { return PostgreSQLDriver(connection: connection) case .redshift: return RedshiftDriver(connection: connection) - case .cockroachdb: - return CockroachDBDriver(connection: connection) case .mongodb: return MongoDBDriver(connection: connection) case .redis: diff --git a/TablePro/Core/Database/DatabaseManager.swift b/TablePro/Core/Database/DatabaseManager.swift index 7d38ee35..a14eef29 100644 --- a/TablePro/Core/Database/DatabaseManager.swift +++ b/TablePro/Core/Database/DatabaseManager.swift @@ -130,13 +130,11 @@ final class DatabaseManager { try await driver.applyQueryTimeout(timeoutSeconds) } - // Initialize schema for PostgreSQL/Redshift/CockroachDB connections + // Initialize schema for PostgreSQL/Redshift connections if let pgDriver = driver as? PostgreSQLDriver { activeSessions[connection.id]?.currentSchema = pgDriver.currentSchema } else if let rsDriver = driver as? RedshiftDriver { activeSessions[connection.id]?.currentSchema = rsDriver.currentSchema - } else if let crdbDriver = driver as? CockroachDBDriver { - activeSessions[connection.id]?.currentSchema = crdbDriver.currentSchema } else if let oracleDriver = driver as? OracleDriver { activeSessions[connection.id]?.currentSchema = oracleDriver.currentSchema } else if connection.type == .redis { @@ -188,14 +186,12 @@ final class DatabaseManager { if metaTimeout > 0 { try? await metaDriver.applyQueryTimeout(metaTimeout) } - // Sync schema on metadata driver for PostgreSQL/Redshift/CockroachDB + // Sync schema on metadata driver for PostgreSQL/Redshift if let savedSchema = self.activeSessions[metaConnectionId]?.currentSchema { if let pgMetaDriver = metaDriver as? PostgreSQLDriver { try? await pgMetaDriver.switchSchema(to: savedSchema) } else if let rsMetaDriver = metaDriver as? RedshiftDriver { try? await rsMetaDriver.switchSchema(to: savedSchema) - } else if let crdbMetaDriver = metaDriver as? CockroachDBDriver { - try? await crdbMetaDriver.switchSchema(to: savedSchema) } else if let oracleMetaDriver = metaDriver as? OracleDriver { try? await oracleMetaDriver.switchSchema(to: savedSchema) } @@ -544,14 +540,12 @@ final class DatabaseManager { try await driver.applyQueryTimeout(timeoutSeconds) } - // Restore schema for PostgreSQL/Redshift/CockroachDB if session had a non-default schema + // Restore schema for PostgreSQL/Redshift if session had a non-default schema if let savedSchema = session.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 crdbDriver = driver as? CockroachDBDriver { - try? await crdbDriver.switchSchema(to: savedSchema) } else if let oracleDriver = driver as? OracleDriver { try? await oracleDriver.switchSchema(to: savedSchema) } @@ -622,14 +616,12 @@ final class DatabaseManager { try await driver.applyQueryTimeout(timeoutSeconds) } - // Restore schema for PostgreSQL/Redshift/CockroachDB/Oracle 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 crdbDriver = driver as? CockroachDBDriver { - try? await crdbDriver.switchSchema(to: savedSchema) } else if let oracleDriver = driver as? OracleDriver { try? await oracleDriver.switchSchema(to: savedSchema) } @@ -666,8 +658,6 @@ 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 crdbMetaDriver = metaDriver as? CockroachDBDriver { - try? await crdbMetaDriver.switchSchema(to: savedSchema) } else if let oracleMetaDriver = metaDriver as? OracleDriver { try? await oracleMetaDriver.switchSchema(to: savedSchema) } @@ -816,7 +806,7 @@ final class DatabaseManager { driver: DatabaseDriver ) async -> String? { // Only needed for PostgreSQL PK modifications - guard databaseType == .postgresql || databaseType == .redshift || databaseType == .cockroachdb else { return nil } + guard databaseType == .postgresql || databaseType == .redshift else { return nil } guard changes.contains(where: { if case .modifyPrimaryKey = $0 { return true } @@ -833,8 +823,6 @@ final class DatabaseManager { schema = pgDriver.escapedSchema } else if let rsDriver = driver as? RedshiftDriver { schema = rsDriver.escapedSchema - } else if let crdbDriver = driver as? CockroachDBDriver { - schema = crdbDriver.escapedSchema } else { schema = "public" } diff --git a/TablePro/Core/Database/SQLEscaping.swift b/TablePro/Core/Database/SQLEscaping.swift index 3d428ae1..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, .cockroachdb, .sqlite, .mongodb, .redis, .mssql, .oracle: + 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 87ea03da..e50462e8 100644 --- a/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift +++ b/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift @@ -158,7 +158,7 @@ struct SchemaStatementGenerator { isDestructive: old.dataType != new.dataType ) - case .postgresql, .redshift, .cockroachdb: + case .postgresql, .redshift: // PostgreSQL: Multiple ALTER COLUMN statements var statements: [String] = [] let oldQuoted = databaseType.quoteIdentifier(old.name) @@ -293,7 +293,7 @@ struct SchemaStatementGenerator { switch databaseType { case .mysql, .mariadb: parts.append("AUTO_INCREMENT") - case .postgresql, .redshift, .cockroachdb: + case .postgresql, .redshift: // PostgreSQL uses SERIAL or IDENTITY // For simplicity, we'll use SERIAL parts[1] = "SERIAL" @@ -320,7 +320,7 @@ struct SchemaStatementGenerator { case .mysql, .mariadb: let escapedComment = comment.replacingOccurrences(of: "'", with: "''") parts.append("COMMENT '\(escapedComment)'") - case .postgresql, .redshift, .cockroachdb: + case .postgresql, .redshift: // PostgreSQL comments are set via separate COMMENT statement break case .sqlite, .mongodb, .redis, .mssql, .oracle: @@ -347,7 +347,7 @@ struct SchemaStatementGenerator { let indexType = index.type.rawValue sql = "CREATE \(uniqueKeyword)INDEX \(indexQuoted) ON \(tableQuoted) (\(columnsQuoted)) USING \(indexType)" - case .postgresql, .redshift, .cockroachdb: + case .postgresql, .redshift: let indexTypeClause = index.type == .btree ? "" : "USING \(index.type.rawValue)" sql = "CREATE \(uniqueKeyword)INDEX \(indexQuoted) ON \(tableQuoted) \(indexTypeClause) (\(columnsQuoted))" @@ -384,7 +384,7 @@ struct SchemaStatementGenerator { let tableQuoted = databaseType.quoteIdentifier(tableName) sql = "DROP INDEX \(indexQuoted) ON \(tableQuoted)" - case .postgresql, .redshift, .cockroachdb, .sqlite, .mongodb, .redis, .oracle: + case .postgresql, .redshift, .sqlite, .mongodb, .redis, .oracle: sql = "DROP INDEX \(indexQuoted)" case .mssql: let tableQuoted = databaseType.quoteIdentifier(tableName) @@ -445,7 +445,7 @@ struct SchemaStatementGenerator { case .mysql, .mariadb: sql = "ALTER TABLE \(tableQuoted) DROP FOREIGN KEY \(fkQuoted)" - case .postgresql, .redshift, .cockroachdb, .mssql, .oracle: + case .postgresql, .redshift, .mssql, .oracle: sql = "ALTER TABLE \(tableQuoted) DROP CONSTRAINT \(fkQuoted)" case .sqlite, .mongodb, .redis: throw DatabaseError.unsupportedOperation @@ -471,7 +471,7 @@ struct SchemaStatementGenerator { ALTER TABLE \(tableQuoted) ADD PRIMARY KEY (\(newColumnsQuoted)); """ - case .postgresql, .redshift, .cockroachdb: + case .postgresql, .redshift: // Use actual constraint name if available, otherwise fall back to convention let pkName = primaryKeyConstraintName ?? "\(tableName)_pkey" sql = """ diff --git a/TablePro/Core/Services/ImportService.swift b/TablePro/Core/Services/ImportService.swift index 03edcb49..1bb13d83 100644 --- a/TablePro/Core/Services/ImportService.swift +++ b/TablePro/Core/Services/ImportService.swift @@ -289,7 +289,7 @@ final class ImportService { switch dbType { case .mysql, .mariadb: return ["SET FOREIGN_KEY_CHECKS=0"] - case .postgresql, .redshift, .cockroachdb, .mssql, .oracle: + case .postgresql, .redshift, .mssql, .oracle: // These databases don't support globally disabling non-deferrable FKs. return [] case .sqlite: @@ -303,7 +303,7 @@ final class ImportService { switch dbType { case .mysql, .mariadb: return ["SET FOREIGN_KEY_CHECKS=1"] - case .postgresql, .redshift, .cockroachdb, .mssql, .oracle: + case .postgresql, .redshift, .mssql, .oracle: return [] case .sqlite: return ["PRAGMA foreign_keys = ON"] diff --git a/TablePro/Core/Services/SQLDialectProvider.swift b/TablePro/Core/Services/SQLDialectProvider.swift index ba9d512c..d6af1348 100644 --- a/TablePro/Core/Services/SQLDialectProvider.swift +++ b/TablePro/Core/Services/SQLDialectProvider.swift @@ -348,7 +348,7 @@ struct SQLDialectFactory { switch databaseType { case .mysql, .mariadb: return MySQLDialect() - case .postgresql, .redshift, .cockroachdb: + case .postgresql, .redshift: return PostgreSQLDialect() case .sqlite: return SQLiteDialect() diff --git a/TablePro/Core/Utilities/SQLParameterInliner.swift b/TablePro/Core/Utilities/SQLParameterInliner.swift index cf4497d5..472fcdfe 100644 --- a/TablePro/Core/Utilities/SQLParameterInliner.swift +++ b/TablePro/Core/Utilities/SQLParameterInliner.swift @@ -19,7 +19,7 @@ struct SQLParameterInliner { /// - Returns: A SQL string with placeholders replaced by formatted literal values. static func inline(_ statement: ParameterizedStatement, databaseType: DatabaseType) -> String { switch databaseType { - case .postgresql, .redshift, .cockroachdb: + case .postgresql, .redshift: return inlineDollarPlaceholders(statement.sql, parameters: statement.parameters) case .mysql, .mariadb, .sqlite, .mongodb, .redis, .mssql, .oracle: return inlineQuestionMarkPlaceholders(statement.sql, parameters: statement.parameters) 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 56c6f6a6..18d978a5 100644 --- a/TablePro/Theme/Theme.swift +++ b/TablePro/Theme/Theme.swift @@ -22,7 +22,6 @@ 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 cockroachdbColor = Color(red: 0.24, green: 0.30, blue: 0.87) static let oracleColor = Color(red: 0.76, green: 0.09, blue: 0.07) // #C3160B Oracle red // MARK: - Semantic Colors @@ -110,8 +109,6 @@ extension DatabaseType { return Theme.sqliteColor case .redshift: return Theme.redshiftColor - case .cockroachdb: - return Theme.cockroachdbColor case .mongodb: return Theme.mongodbColor case .redis: diff --git a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift index 61863ba3..928863bb 100644 --- a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift +++ b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift @@ -33,7 +33,7 @@ final class DatabaseSwitcherViewModel { var showPreview = false var mode: Mode - /// Whether we're switching schemas (Redshift, CockroachDB, or PostgreSQL in schema mode) + /// Whether we're switching schemas (Redshift or PostgreSQL in schema mode) var isSchemaMode: Bool { mode == .schema } // MARK: - Dependencies @@ -77,7 +77,7 @@ final class DatabaseSwitcherViewModel { self.currentDatabase = currentDatabase self.currentSchema = currentSchema self.databaseType = databaseType - self.mode = (databaseType == .redshift || databaseType == .cockroachdb) ? .schema : .database + self.mode = databaseType == .redshift ? .schema : .database self.recentDatabases = UserDefaults.standard.recentDatabases(for: connectionId) } @@ -180,8 +180,6 @@ final class DatabaseSwitcherViewModel { return ["postgres", "template0", "template1"].contains(name) case .redshift: return ["dev", "padb_harvest"].contains(name) - case .cockroachdb: - return ["system", "defaultdb"].contains(name) case .sqlite: return false case .mongodb: diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 9e8164b7..b43dabb5 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -573,7 +573,6 @@ struct ConnectionFormView: View { case .mysql, .mariadb: return "3306" case .postgresql: return "5432" case .redshift: return "5439" - case .cockroachdb: return "26257" case .sqlite: return "" case .mongodb: return "27017" case .redis: return "6379" diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift index 299ddade..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 != .cockroachdb && dbType != .mssql && dbType != .oracle && 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, .cockroachdb, .mongodb, .redis, .mssql, .oracle: 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, .cockroachdb, .mongodb, .redis, .mssql, .oracle: + case .postgresql, .redshift, .mongodb, .redis, .mssql, .oracle: return [] case .sqlite: return ["PRAGMA foreign_keys = ON"] @@ -109,7 +109,7 @@ extension MainContentCoordinator { switch dbType { case .mysql, .mariadb: return ["TRUNCATE TABLE \(quotedName)"] - case .postgresql, .redshift, .cockroachdb: + case .postgresql, .redshift: let cascade = options.cascade ? " CASCADE" : "" return ["TRUNCATE TABLE \(quotedName)\(cascade)"] case .mssql, .oracle: @@ -139,7 +139,7 @@ extension MainContentCoordinator { private func dropTableStatement(tableName: String, quotedName: String, isView: Bool, options: TableOperationOptions, dbType: DatabaseType) -> String { let keyword = isView ? "VIEW" : "TABLE" switch dbType { - case .postgresql, .redshift, .cockroachdb: + case .postgresql, .redshift: return "DROP \(keyword) \(quotedName)\(options.cascade ? " CASCADE" : "")" case .mysql, .mariadb, .sqlite, .mssql, .oracle: return "DROP \(keyword) \(quotedName)" diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 74982e37..d9645952 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -308,7 +308,7 @@ final class MainContentCommandActions { let template: String switch connection.type { - case .postgresql, .redshift, .cockroachdb: + case .postgresql, .redshift: template = "CREATE OR REPLACE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;" case .mysql, .mariadb: template = "CREATE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;" @@ -614,7 +614,7 @@ final class MainContentCommandActions { } catch { let fallbackSQL: String switch connection.type { - case .postgresql, .redshift, .cockroachdb: + case .postgresql, .redshift: fallbackSQL = "CREATE OR REPLACE VIEW \(viewName) AS\n-- Could not fetch view definition: \(error.localizedDescription)\nSELECT * FROM table_name;" case .mysql, .mariadb: fallbackSQL = "ALTER VIEW \(viewName) AS\n-- Could not fetch view definition: \(error.localizedDescription)\nSELECT * FROM table_name;" diff --git a/TablePro/Views/Structure/TypePickerContentView.swift b/TablePro/Views/Structure/TypePickerContentView.swift index 47c8cb5b..1dcc52c5 100644 --- a/TablePro/Views/Structure/TypePickerContentView.swift +++ b/TablePro/Views/Structure/TypePickerContentView.swift @@ -21,7 +21,7 @@ enum DataTypeCategory: String, CaseIterable { switch dbType { case .mysql, .mariadb: return ["TINYINT", "SMALLINT", "MEDIUMINT", "INT", "BIGINT", "DECIMAL", "NUMERIC", "FLOAT", "DOUBLE", "BIT"] - case .postgresql, .redshift, .cockroachdb: + case .postgresql, .redshift: 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"] @@ -38,7 +38,7 @@ enum DataTypeCategory: String, CaseIterable { switch dbType { case .mysql, .mariadb: return ["CHAR", "VARCHAR", "TINYTEXT", "TEXT", "MEDIUMTEXT", "LONGTEXT"] - case .postgresql, .redshift, .cockroachdb: + case .postgresql, .redshift: return ["CHAR", "VARCHAR", "TEXT"] case .mssql: return ["CHAR", "VARCHAR", "NCHAR", "NVARCHAR", "TEXT", "NTEXT"] @@ -55,7 +55,7 @@ enum DataTypeCategory: String, CaseIterable { switch dbType { case .mysql, .mariadb: return ["DATE", "TIME", "DATETIME", "TIMESTAMP", "YEAR"] - case .postgresql, .redshift, .cockroachdb: + case .postgresql, .redshift: return ["DATE", "TIME", "TIMESTAMP", "TIMESTAMPTZ", "INTERVAL"] case .mssql: return ["DATE", "TIME", "DATETIME", "DATETIME2", "SMALLDATETIME", "DATETIMEOFFSET"] @@ -72,7 +72,7 @@ enum DataTypeCategory: String, CaseIterable { switch dbType { case .mysql, .mariadb: return ["BINARY", "VARBINARY", "TINYBLOB", "BLOB", "MEDIUMBLOB", "LONGBLOB"] - case .postgresql, .redshift, .cockroachdb: + case .postgresql, .redshift: return ["BYTEA"] case .mssql: return ["BINARY", "VARBINARY", "IMAGE"] @@ -89,7 +89,7 @@ enum DataTypeCategory: String, CaseIterable { switch dbType { case .mysql, .mariadb: return ["BOOLEAN", "ENUM", "SET", "JSON"] - case .postgresql, .redshift, .cockroachdb: + case .postgresql, .redshift: return ["BOOLEAN", "UUID", "JSON", "JSONB", "ARRAY", "HSTORE", "INET", "CIDR", "MACADDR", "TSVECTOR", "TSQUERY"] case .mssql: return ["BIT", "UNIQUEIDENTIFIER", "XML", "SQL_VARIANT", "ROWVERSION", "HIERARCHYID"] diff --git a/docs/databases/connection-urls.mdx b/docs/databases/connection-urls.mdx index f32d714d..42cc9aa0 100644 --- a/docs/databases/connection-urls.mdx +++ b/docs/databases/connection-urls.mdx @@ -21,7 +21,6 @@ TablePro parses standard database connection URLs for importing connections, ope | `redis://` | Redis | | `rediss://` | Redis with TLS | | `redshift://` | Amazon Redshift | -| `cockroachdb://` | CockroachDB | | `mssql://` | Microsoft SQL Server | | `sqlserver://` | Microsoft SQL Server (alias) | | `oracle://` | Oracle Database | diff --git a/docs/vi/databases/connection-urls.mdx b/docs/vi/databases/connection-urls.mdx index 5e1c16f3..bd17b287 100644 --- a/docs/vi/databases/connection-urls.mdx +++ b/docs/vi/databases/connection-urls.mdx @@ -21,7 +21,6 @@ TablePro phân tích URL kết nối database chuẩn để import kết nối, | `redis://` | Redis | | `rediss://` | Redis với TLS | | `redshift://` | Amazon Redshift | -| `cockroachdb://` | CockroachDB | | `mssql://` | Microsoft SQL Server | | `sqlserver://` | Microsoft SQL Server (alias) | | `oracle://` | Oracle Database | diff --git a/docs/vi/databases/overview.mdx b/docs/vi/databases/overview.mdx index 3350b17e..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ợ 10 hệ thống cơ sở dữ liệu: +TablePro hỗ trợ 9 hệ thống cơ sở dữ liệu: @@ -102,7 +102,7 @@ open "mysql://root:secret@localhost:3306/myapp" open "redis://:password@localhost:6379" ``` -TablePro đăng ký `postgresql`, `postgres`, `mysql`, `mariadb`, `sqlite`, `mongodb`, `mongodb+srv`, `redis`, `rediss`, `redshift`, `mssql`, `sqlserver`, `cockroachdb` 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. +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:** @@ -125,7 +125,7 @@ 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 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, Oracle hoặc CockroachDB | +| **Type** | Loại cơ sở dữ liệu: MySQL, MariaDB, PostgreSQL, SQLite, MongoDB, Redis, Redshift, SQL Server hoặc Oracle | #### Phần Appearance @@ -567,9 +567,6 @@ TablePro tự động điền cổng khi chọn loại database: Kết nối kho dữ liệu Redshift - - Kết nối CockroachDB distributed SQL - Kết nối an toàn qua SSH