diff --git a/CHANGELOG.md b/CHANGELOG.md index 70782221..7ab82ec8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,19 @@ 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 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/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/cockroachdb-icon.imageset/Contents.json b/TablePro/Assets.xcassets/cockroachdb-icon.imageset/Contents.json new file mode 100644 index 00000000..8f89e74a --- /dev/null +++ b/TablePro/Assets.xcassets/cockroachdb-icon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "cockroachdb.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/TablePro/Assets.xcassets/cockroachdb-icon.imageset/cockroachdb.svg b/TablePro/Assets.xcassets/cockroachdb-icon.imageset/cockroachdb.svg new file mode 100644 index 00000000..4efe28a2 --- /dev/null +++ b/TablePro/Assets.xcassets/cockroachdb-icon.imageset/cockroachdb.svg @@ -0,0 +1,4 @@ + + + + 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..9232ed9f --- /dev/null +++ b/TablePro/Assets.xcassets/oracle-icon.imageset/oracle.svg @@ -0,0 +1 @@ + diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index 59000bb3..a89937a4 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,26 @@ 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() + } + } } return } @@ -146,6 +170,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 +205,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 +237,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 +254,6 @@ struct ContentView: View { .frame(minWidth: 280, maxWidth: 500) .inspectorColumnWidth(min: 280, ideal: 320, max: 500) } - .id(currentSession.id) } .navigationTitle(windowTitle) .navigationSubtitle(currentSession.connection.name) diff --git a/TablePro/Core/Autocomplete/SQLCompletionProvider.swift b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift index 24d801a0..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/CockroachDBDriver.swift b/TablePro/Core/Database/CockroachDBDriver.swift new file mode 100644 index 00000000..b2abda4d --- /dev/null +++ b/TablePro/Core/Database/CockroachDBDriver.swift @@ -0,0 +1,804 @@ +// +// CockroachDBDriver.swift +// TablePro +// +// CockroachDB database driver using libpq (PostgreSQL wire protocol) +// + +import Foundation +import os + +/// CockroachDB database driver using libpq native library +final class CockroachDBDriver: DatabaseDriver, SchemaSwitchable { + let connection: DatabaseConnection + private(set) var status: ConnectionStatus = .disconnected + + private var libpqConnection: LibPQConnection? + + private static let logger = Logger(subsystem: "com.TablePro", category: "CockroachDBDriver") + + private static let limitRegex = try? NSRegularExpression(pattern: "(?i)\\s+LIMIT\\s+\\d+") + private static let offsetRegex = try? NSRegularExpression(pattern: "(?i)\\s+OFFSET\\s+\\d+") + + private(set) var currentSchema: String = "public" + + var escapedSchema: String { + SQLEscaping.escapeStringLiteral(currentSchema, databaseType: .cockroachdb) + } + + var serverVersion: String? { + libpqConnection?.serverVersion() + } + + init(connection: DatabaseConnection) { + self.connection = connection + } + + // MARK: - Connection + + func connect() async throws { + status = .connecting + + let pqConn = LibPQConnection( + host: connection.host, + port: connection.port, + user: connection.username, + password: ConnectionStorage.shared.loadPassword(for: connection.id), + database: connection.database, + sslConfig: connection.sslConfig + ) + + do { + try await pqConn.connect() + self.libpqConnection = pqConn + status = .connected + + if let schemaResult = try? await pqConn.executeQuery("SELECT current_schema()"), + let schema = schemaResult.rows.first?.first.flatMap({ $0 }) { + currentSchema = schema + } + } catch { + status = .error(error.localizedDescription) + throw error + } + } + + func disconnect() { + libpqConnection?.disconnect() + libpqConnection = nil + status = .disconnected + } + + func testConnection() async throws -> Bool { + try await connect() + let isConnected = status == .connected + disconnect() + return isConnected + } + + // MARK: - Query Execution + + func execute(query: String) async throws -> QueryResult { + try await executeWithReconnect(query: query, isRetry: false) + } + + private func executeWithReconnect(query: String, isRetry: Bool) async throws -> QueryResult { + guard let pqConn = libpqConnection else { + throw DatabaseError.connectionFailed("Not connected to CockroachDB") + } + + let startTime = Date() + + do { + let result = try await pqConn.executeQuery(query) + + let columnTypes = zip(result.columnOids, result.columnTypeNames).map { oid, rawType in + ColumnType(fromPostgreSQLOid: oid, rawType: rawType) + } + + return QueryResult( + columns: result.columns, + columnTypes: columnTypes, + rows: result.rows, + rowsAffected: result.affectedRows, + executionTime: Date().timeIntervalSince(startTime), + error: nil, + isTruncated: result.isTruncated + ) + } catch let error as NSError where !isRetry && isConnectionLostError(error) { + try await reconnect() + return try await executeWithReconnect(query: query, isRetry: true) + } catch { + throw DatabaseError.queryFailed(error.localizedDescription) + } + } + + // MARK: - Auto-Reconnect + + private func isConnectionLostError(_ error: NSError) -> Bool { + let errorMessage = error.localizedDescription.lowercased() + return errorMessage.contains("connection") && + (errorMessage.contains("lost") || + errorMessage.contains("closed") || + errorMessage.contains("no connection") || + errorMessage.contains("could not send")) + } + + private func reconnect() async throws { + libpqConnection?.disconnect() + libpqConnection = nil + status = .connecting + try await connect() + } + + // MARK: - Query Cancellation + + func cancelQuery() throws { + libpqConnection?.cancelCurrentQuery() + } + + func executeParameterized(query: String, parameters: [Any?]) async throws -> QueryResult { + try await executeParameterizedWithReconnect(query: query, parameters: parameters, isRetry: false) + } + + private func executeParameterizedWithReconnect( + query: String, + parameters: [Any?], + isRetry: Bool + ) async throws -> QueryResult { + guard let pqConn = libpqConnection else { + throw DatabaseError.connectionFailed("Not connected to CockroachDB") + } + + let startTime = Date() + + do { + let result = try await pqConn.executeParameterizedQuery(query, parameters: parameters) + + let columnTypes = zip(result.columnOids, result.columnTypeNames).map { oid, rawType in + ColumnType(fromPostgreSQLOid: oid, rawType: rawType) + } + + return QueryResult( + columns: result.columns, + columnTypes: columnTypes, + rows: result.rows, + rowsAffected: result.affectedRows, + executionTime: Date().timeIntervalSince(startTime), + error: nil, + isTruncated: result.isTruncated + ) + } catch let error as NSError where !isRetry && isConnectionLostError(error) { + try await reconnect() + return try await executeParameterizedWithReconnect(query: query, parameters: parameters, isRetry: true) + } catch { + throw DatabaseError.queryFailed(error.localizedDescription) + } + } + + // MARK: - Schema + + func fetchTables() async throws -> [TableInfo] { + let query = """ + SELECT table_name, table_type + FROM information_schema.tables + WHERE table_schema = '\(escapedSchema)' + ORDER BY table_name + """ + + let result = try await execute(query: query) + + return result.rows.compactMap { row in + guard let name = row[0] else { return nil } + let typeStr = row[1] ?? "BASE TABLE" + let type: TableInfo.TableType = typeStr.contains("VIEW") ? .view : .table + + return TableInfo(name: name, type: type, rowCount: nil) + } + } + + func fetchColumns(table: String) async throws -> [ColumnInfo] { + let safeTable = SQLEscaping.escapeStringLiteral(table, databaseType: .cockroachdb) + let query = """ + SELECT + c.column_name, + c.data_type, + c.is_nullable, + c.column_default, + c.collation_name, + pgd.description, + c.udt_name + FROM information_schema.columns c + LEFT JOIN pg_catalog.pg_class cls + ON cls.relname = c.table_name + AND cls.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = c.table_schema) + LEFT JOIN pg_catalog.pg_description pgd + ON pgd.objoid = cls.oid + AND pgd.objsubid = c.ordinal_position + WHERE c.table_schema = '\(escapedSchema)' AND c.table_name = '\(safeTable)' + ORDER BY c.ordinal_position + """ + + let result = try await execute(query: query) + + return result.rows.compactMap { row in + guard row.count >= 4, + let name = row[0], + let rawDataType = row[1] + else { + return nil + } + + let udtName = row.count > 6 ? row[6] : nil + + let dataType: String + if rawDataType.uppercased() == "USER-DEFINED", let udt = udtName { + dataType = "ENUM(\(udt))" + } else { + dataType = rawDataType.uppercased() + } + + let isNullable = row[2] == "YES" + let defaultValue = row[3] + let collation = row.count > 4 ? row[4] : nil + let comment = row.count > 5 ? row[5] : nil + + let charset: String? = { + guard let coll = collation else { return nil } + if coll.contains(".") { + return coll.components(separatedBy: ".").last + } + return nil + }() + + return ColumnInfo( + name: name, + dataType: dataType, + isNullable: isNullable, + isPrimaryKey: false, + defaultValue: defaultValue, + extra: nil, + charset: charset, + collation: collation, + comment: comment?.isEmpty == false ? comment : nil + ) + } + } + + func fetchAllColumns() async throws -> [String: [ColumnInfo]] { + let query = """ + SELECT + c.table_name, + c.column_name, + c.data_type, + c.is_nullable, + c.column_default, + c.collation_name, + pgd.description, + c.udt_name + FROM information_schema.columns c + LEFT JOIN pg_catalog.pg_class cls + ON cls.relname = c.table_name + AND cls.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = c.table_schema) + LEFT JOIN pg_catalog.pg_description pgd + ON pgd.objoid = cls.oid + AND pgd.objsubid = c.ordinal_position + WHERE c.table_schema = '\(escapedSchema)' + ORDER BY c.table_name, c.ordinal_position + """ + + let result = try await execute(query: query) + + var allColumns: [String: [ColumnInfo]] = [:] + for row in result.rows { + guard row.count >= 5, + let tableName = row[0], + let name = row[1], + let rawDataType = row[2] + else { + continue + } + + let udtName = row.count > 7 ? row[7] : nil + + let dataType: String + if rawDataType.uppercased() == "USER-DEFINED", let udt = udtName { + dataType = "ENUM(\(udt))" + } else { + dataType = rawDataType.uppercased() + } + + let isNullable = row[3] == "YES" + let defaultValue = row[4] + let collation = row.count > 5 ? row[5] : nil + let comment = row.count > 6 ? row[6] : nil + + let charset: String? = { + guard let coll = collation else { return nil } + if coll.contains(".") { + return coll.components(separatedBy: ".").last + } + return nil + }() + + let column = ColumnInfo( + name: name, + dataType: dataType, + isNullable: isNullable, + isPrimaryKey: false, + defaultValue: defaultValue, + extra: nil, + charset: charset, + collation: collation, + comment: comment?.isEmpty == false ? comment : nil + ) + + allColumns[tableName, default: []].append(column) + } + + return allColumns + } + + func fetchIndexes(table: String) async throws -> [IndexInfo] { + let safeTable = SQLEscaping.escapeStringLiteral(table, databaseType: .cockroachdb) + let query = """ + SELECT + i.relname AS index_name, + array_to_string(array_agg(a.attname ORDER BY x.ordinality), ', ') AS columns, + ix.indisunique, + ix.indisprimary, + am.amname AS index_type + FROM pg_index ix + JOIN pg_class t ON t.oid = ix.indrelid + JOIN pg_class i ON i.oid = ix.indexrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + JOIN pg_am am ON am.oid = i.relam + CROSS JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS x(attnum, ordinality) + JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = x.attnum + WHERE t.relname = '\(safeTable)' + AND n.nspname = '\(escapedSchema)' + GROUP BY i.relname, ix.indisunique, ix.indisprimary, am.amname + ORDER BY i.relname + """ + + let result = try await execute(query: query) + + return result.rows.compactMap { row in + guard row.count >= 5, + let name = row[0], + let columnsStr = row[1] + else { + return nil + } + + let columns = columnsStr.components(separatedBy: ", ") + let isUnique = row[2] == "t" + let isPrimary = row[3] == "t" + let indexType = row[4] ?? "btree" + + return IndexInfo( + name: name, + columns: columns, + isUnique: isUnique, + isPrimary: isPrimary, + type: indexType + ) + } + } + + func fetchForeignKeys(table: String) async throws -> [ForeignKeyInfo] { + let safeTable = SQLEscaping.escapeStringLiteral(table, databaseType: .cockroachdb) + let query = """ + SELECT + tc.constraint_name, + kcu.column_name, + ccu.table_name AS referenced_table, + ccu.column_name AS referenced_column, + rc.delete_rule, + rc.update_rule + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + JOIN information_schema.referential_constraints rc + ON tc.constraint_name = rc.constraint_name + JOIN information_schema.constraint_column_usage ccu + ON rc.unique_constraint_name = ccu.constraint_name + WHERE tc.table_name = '\(safeTable)' + AND tc.constraint_type = 'FOREIGN KEY' + ORDER BY tc.constraint_name + """ + + let result = try await execute(query: query) + + return result.rows.compactMap { row in + guard row.count >= 6, + let name = row[0], + let column = row[1], + let refTable = row[2], + let refColumn = row[3] + else { + return nil + } + + return ForeignKeyInfo( + name: name, + column: column, + referencedTable: refTable, + referencedColumn: refColumn, + onDelete: row[4] ?? "NO ACTION", + onUpdate: row[5] ?? "NO ACTION" + ) + } + } + + func fetchApproximateRowCount(table: String) async throws -> Int? { + let safeTable = SQLEscaping.escapeStringLiteral(table, databaseType: .cockroachdb) + + // Try CockroachDB-specific table_row_statistics first + do { + let query = """ + SELECT estimated_row_count + FROM crdb_internal.table_row_statistics + WHERE table_name = '\(safeTable)' + """ + let result = try await execute(query: query) + if let firstRow = result.rows.first, + let value = firstRow[0], + let count = Int(value) { + return count >= 0 ? count : nil + } + } catch { + Self.logger.debug("crdb_internal.table_row_statistics not available, falling back to pg_class") + } + + // Fall back to PostgreSQL's pg_class.reltuples + let fallbackQuery = """ + SELECT reltuples::BIGINT + FROM pg_class + WHERE relname = '\(safeTable)' + AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = '\(escapedSchema)') + """ + let fallbackResult = try await execute(query: fallbackQuery) + guard let firstRow = fallbackResult.rows.first, + let value = firstRow[0], + let count = Int(value) + else { + return nil + } + return count >= 0 ? count : nil + } + + func fetchTableDDL(table: String) async throws -> String { + let safeTable = SQLEscaping.escapeStringLiteral(table, databaseType: .cockroachdb) + let quotedTable = "\"\(table.replacingOccurrences(of: "\"", with: "\"\""))\"" + let quotedSchema = "\"\(currentSchema.replacingOccurrences(of: "\"", with: "\"\""))\"" + + // Try SHOW CREATE TABLE first (CockroachDB supports this) + do { + let showResult = try await execute(query: "SHOW CREATE TABLE \(quotedSchema).\(quotedTable)") + if let firstRow = showResult.rows.first { + // CockroachDB returns (table_name, create_statement) — DDL is in column 1 + let ddl = firstRow.count > 1 ? firstRow[1] : firstRow[0] + if let ddl, !ddl.isEmpty { + return ddl + } + } + } catch { + Self.logger.debug("SHOW CREATE TABLE not available, falling back to manual reconstruction") + } + + // Fall back to manual reconstruction from pg_class/pg_attribute + let columnsQuery = """ + SELECT + quote_ident(a.attname) || ' ' || format_type(a.atttypid, a.atttypmod) || + CASE WHEN a.attnotnull THEN ' NOT NULL' ELSE '' END || + CASE WHEN a.atthasdef THEN ' DEFAULT ' || pg_get_expr(d.adbin, d.adrelid) ELSE '' END + FROM pg_attribute a + JOIN pg_class c ON c.oid = a.attrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + LEFT JOIN pg_attrdef d ON d.adrelid = c.oid AND d.adnum = a.attnum + WHERE c.relname = '\(safeTable)' + AND n.nspname = '\(escapedSchema)' + AND a.attnum > 0 + AND NOT a.attisdropped + ORDER BY a.attnum + """ + + let columnsResult = try await execute(query: columnsQuery) + let columnDefs = columnsResult.rows.compactMap { $0[0] } + + guard !columnDefs.isEmpty else { + throw DatabaseError.queryFailed("Failed to fetch DDL for table '\(table)'") + } + + let ddl = "CREATE TABLE \(quotedSchema).\(quotedTable) (\n " + + columnDefs.joined(separator: ",\n ") + + "\n);" + + return ddl + } + + func fetchViewDefinition(view: String) async throws -> String { + let safeView = SQLEscaping.escapeStringLiteral(view, databaseType: .cockroachdb) + let query = """ + SELECT 'CREATE OR REPLACE VIEW ' || quote_ident(schemaname) || '.' || quote_ident(viewname) || ' AS ' || E'\\n' || definition AS ddl + FROM pg_views + WHERE viewname = '\(safeView)' + AND schemaname = '\(escapedSchema)' + """ + + let result = try await execute(query: query) + + guard let firstRow = result.rows.first, + let ddl = firstRow[0] + else { + throw DatabaseError.queryFailed("Failed to fetch definition for view '\(view)'") + } + + return ddl + } + + // MARK: - Paginated Query Support + + func fetchRowCount(query: String) async throws -> Int { + let baseQuery = stripLimitOffset(from: query) + let countQuery = "SELECT COUNT(*) FROM (\(baseQuery)) AS __count_subquery__" + + let result = try await execute(query: countQuery) + guard let firstRow = result.rows.first, let countStr = firstRow.first else { return 0 } + return Int(countStr ?? "0") ?? 0 + } + + func fetchRows(query: String, offset: Int, limit: Int) async throws -> QueryResult { + let baseQuery = stripLimitOffset(from: query) + let paginatedQuery = "\(baseQuery) LIMIT \(limit) OFFSET \(offset)" + return try await execute(query: paginatedQuery) + } + + func fetchTableMetadata(tableName: String) async throws -> TableMetadata { + let safeTable = SQLEscaping.escapeStringLiteral(tableName, databaseType: .cockroachdb) + let quotedTable = "\"\(tableName.replacingOccurrences(of: "\"", with: "\"\""))\"" + let quotedSchema = "\"\(currentSchema.replacingOccurrences(of: "\"", with: "\"\""))\"" + let fullName = "\(quotedSchema).\(quotedTable)" + + // Use PostgreSQL-compatible size functions (CockroachDB supports these) + let query = """ + SELECT + pg_total_relation_size('\(fullName)') AS total_size, + pg_table_size('\(fullName)') AS data_size, + pg_indexes_size('\(fullName)') AS index_size, + c.reltuples::bigint AS row_count, + CASE WHEN c.reltuples > 0 THEN pg_table_size('\(fullName)') / GREATEST(c.reltuples, 1) ELSE 0 END AS avg_row_length, + obj_description(c.oid, 'pg_class') AS comment + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relname = '\(safeTable)' + AND n.nspname = '\(escapedSchema)' + """ + + let result = try await execute(query: query) + + guard let row = result.rows.first else { + return TableMetadata( + tableName: tableName, + dataSize: nil, + indexSize: nil, + totalSize: nil, + avgRowLength: nil, + rowCount: nil, + comment: nil, + engine: nil, + collation: nil, + createTime: nil, + updateTime: nil + ) + } + + let totalSize = !row.isEmpty ? Int64(row[0] ?? "0") : nil + let dataSize = row.count > 1 ? Int64(row[1] ?? "0") : nil + let indexSize = row.count > 2 ? Int64(row[2] ?? "0") : nil + let rowCount = row.count > 3 ? Int64(row[3] ?? "0") : nil + let avgRowLength = row.count > 4 ? Int64(row[4] ?? "0") : nil + let comment = row.count > 5 ? row[5] : nil + + return TableMetadata( + tableName: tableName, + dataSize: dataSize, + indexSize: indexSize, + totalSize: totalSize, + avgRowLength: avgRowLength, + rowCount: rowCount, + comment: comment?.isEmpty == true ? nil : comment, + engine: "CockroachDB", + collation: nil, + createTime: nil, + updateTime: nil + ) + } + + private func stripLimitOffset(from query: String) -> String { + var result = query + + if let regex = Self.limitRegex { + result = regex.stringByReplacingMatches( + in: result, range: NSRange(result.startIndex..., in: result), withTemplate: "") + } + + if let regex = Self.offsetRegex { + result = regex.stringByReplacingMatches( + in: result, range: NSRange(result.startIndex..., in: result), withTemplate: "") + } + + return result.trimmingCharacters(in: .whitespacesAndNewlines) + } + + // MARK: - Database Operations + + func fetchDatabases() async throws -> [String] { + let result = try await execute( + query: "SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname" + ) + return result.rows.compactMap { row in row.first.flatMap { $0 } } + } + + func fetchSchemas() async throws -> [String] { + let result = try await execute(query: """ + SELECT schema_name FROM information_schema.schemata + WHERE schema_name NOT LIKE 'pg_%' + AND schema_name <> 'information_schema' + AND schema_name <> 'crdb_internal' + ORDER BY schema_name + """) + return result.rows.compactMap { row in row.first.flatMap { $0 } } + } + + func switchSchema(to schema: String) async throws { + let escapedName = schema.replacingOccurrences(of: "\"", with: "\"\"") + _ = try await execute(query: "SET search_path TO \"\(escapedName)\", public") + currentSchema = schema + } + + func fetchDatabaseMetadata(_ database: String) async throws -> DatabaseMetadata { + let escapedDbLiteral = SQLEscaping.escapeStringLiteral(database, databaseType: .cockroachdb) + + let query = """ + SELECT + (SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = 'public' AND table_catalog = '\(escapedDbLiteral)'), + pg_database_size('\(escapedDbLiteral)') + """ + let result = try await execute(query: query) + let row = result.rows.first + let tableCount = Int(row?[0] ?? "0") ?? 0 + let sizeBytes = Int64(row?[1] ?? "0") ?? 0 + + let systemDatabases = ["system", "defaultdb"] + let isSystem = systemDatabases.contains(database) + + return DatabaseMetadata( + id: database, + name: database, + tableCount: tableCount, + sizeBytes: sizeBytes, + lastAccessed: nil, + isSystemDatabase: isSystem, + icon: isSystem ? "gearshape.fill" : "cylinder.fill" + ) + } + + func fetchAllDatabaseMetadata() async throws -> [DatabaseMetadata] { + let systemDatabases = ["system", "defaultdb"] + + let query = """ + SELECT d.datname, pg_database_size(d.datname) + FROM pg_database d + WHERE d.datistemplate = false + ORDER BY d.datname + """ + let result = try await execute(query: query) + + return result.rows.compactMap { row in + guard let dbName = row[0] else { return nil } + let sizeBytes = Int64(row[1] ?? "0") ?? 0 + let isSystem = systemDatabases.contains(dbName) + + return DatabaseMetadata( + id: dbName, + name: dbName, + tableCount: nil, + sizeBytes: sizeBytes, + lastAccessed: nil, + isSystemDatabase: isSystem, + icon: isSystem ? "gearshape.fill" : "cylinder.fill" + ) + } + } + + func createDatabase(name: String, charset: String, collation: String?) async throws { + let escapedName = name.replacingOccurrences(of: "\"", with: "\"\"") + + let validCharsets = ["UTF8"] + let normalizedCharset = charset.uppercased() + guard validCharsets.contains(normalizedCharset) else { + throw DatabaseError.queryFailed("Invalid encoding: \(charset)") + } + + var query = "CREATE DATABASE \"\(escapedName)\" ENCODING '\(normalizedCharset)'" + + if let collation = collation { + let allowedCollationChars = CharacterSet( + charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-" + ) + let isValidCollation = collation.unicodeScalars.allSatisfy { allowedCollationChars.contains($0) } + guard isValidCollation else { + throw DatabaseError.queryFailed("Invalid collation") + } + let escapedCollation = collation.replacingOccurrences(of: "'", with: "''") + query += " LC_COLLATE '\(escapedCollation)'" + } + + _ = try await execute(query: query) + } + + // MARK: - Dependent Types & Sequences + + func fetchDependentTypes(forTable table: String) async throws -> [(name: String, labels: [String])] { + let safeTable = SQLEscaping.escapeStringLiteral(table, databaseType: .cockroachdb) + let query = """ + SELECT DISTINCT t.typname, + array_to_string(array_agg(e.enumlabel ORDER BY e.enumsortorder), ',') + FROM pg_attribute a + JOIN pg_class c ON c.oid = a.attrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + JOIN pg_type t ON t.oid = a.atttypid + JOIN pg_enum e ON e.enumtypid = t.oid + WHERE c.relname = '\(safeTable)' + AND n.nspname = '\(escapedSchema)' + AND a.attnum > 0 + AND NOT a.attisdropped + GROUP BY t.typname + ORDER BY t.typname + """ + let result = try await execute(query: query) + return result.rows.compactMap { row in + guard let typeName = row[0], let labelsStr = row[1] else { return nil } + let labels = labelsStr.components(separatedBy: ",") + return (name: typeName, labels: labels) + } + } + + func fetchDependentSequences(forTable table: String) async throws -> [(name: String, ddl: String)] { + let safeTable = SQLEscaping.escapeStringLiteral(table, databaseType: .cockroachdb) + let query = """ + SELECT s.sequencename, + s.start_value, + s.min_value, + s.max_value, + s.increment_by, + s.cycle + FROM pg_attrdef ad + JOIN pg_class c ON c.oid = ad.adrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + JOIN pg_sequences s ON s.schemaname = n.nspname + AND pg_get_expr(ad.adbin, ad.adrelid) LIKE '%' || quote_ident(s.sequencename) || '%' + WHERE c.relname = '\(safeTable)' + AND n.nspname = '\(escapedSchema)' + AND pg_get_expr(ad.adbin, ad.adrelid) LIKE '%nextval%' + """ + let result = try await execute(query: query) + return result.rows.compactMap { row in + guard let seqName = row[0] else { return nil } + let startVal = row[1] ?? "1" + let minVal = row[2] ?? "1" + let maxVal = row[3] ?? "9223372036854775807" + let incrementBy = row[4] ?? "1" + let cycle = row[5] == "t" ? " CYCLE" : "" + let quotedSeqName = "\"\(seqName.replacingOccurrences(of: "\"", with: "\"\""))\"" + let ddl = "CREATE SEQUENCE \(quotedSeqName) INCREMENT BY \(incrementBy)" + + " MINVALUE \(minVal) MAXVALUE \(maxVal)" + + " START WITH \(startVal)\(cycle);" + return (name: seqName, ddl: ddl) + } + } +} diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 13e1e2b5..57df7a63 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -144,6 +144,16 @@ protocol DatabaseDriver: AnyObject { func rollbackTransaction() async throws } +// MARK: - Schema Switching + +/// Protocol for drivers that support schema/search_path switching. +/// Eliminates repeated as? casting chains in DatabaseManager. +protocol SchemaSwitchable: DatabaseDriver { + var currentSchema: String { get } + var escapedSchema: String { get } + func switchSchema(to schema: String) async throws +} + /// Default implementation for common operations extension DatabaseDriver { /// Default implementation returns nil @@ -265,7 +275,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 +285,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 +302,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 +312,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 +340,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..733c050a 100644 --- a/TablePro/Core/Database/DatabaseManager.swift +++ b/TablePro/Core/Database/DatabaseManager.swift @@ -134,11 +134,9 @@ final class DatabaseManager { try await driver.applyQueryTimeout(timeoutSeconds) } - // 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 + // Initialize schema for drivers that support schema switching + if let schemaDriver = driver as? SchemaSwitchable { + activeSessions[connection.id]?.currentSchema = schemaDriver.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,13 +196,9 @@ final class DatabaseManager { if metaTimeout > 0 { try? await metaDriver.applyQueryTimeout(metaTimeout) } - // 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) - } + if let savedSchema = self.activeSessions[metaConnectionId]?.currentSchema, + let schemaMetaDriver = metaDriver as? SchemaSwitchable { + try? await schemaMetaDriver.switchSchema(to: savedSchema) } activeSessions[metaConnectionId]?.metadataDriver = metaDriver } catch { @@ -545,13 +539,9 @@ final class DatabaseManager { try await driver.applyQueryTimeout(timeoutSeconds) } - // 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) - } + if let savedSchema = session.currentSchema, + let schemaDriver = driver as? SchemaSwitchable { + try? await schemaDriver.switchSchema(to: savedSchema) } // Restore database for MSSQL if session had a non-default database @@ -619,13 +609,9 @@ final class DatabaseManager { try await driver.applyQueryTimeout(timeoutSeconds) } - // Restore schema for PostgreSQL/Redshift 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) - } + if let savedSchema = activeSessions[sessionId]?.currentSchema, + let schemaDriver = driver as? SchemaSwitchable { + try? await schemaDriver.switchSchema(to: savedSchema) } // Restore database for MSSQL if session had a non-default database @@ -653,13 +639,9 @@ final class DatabaseManager { if metaTimeout > 0 { try? await metaDriver.applyQueryTimeout(metaTimeout) } - // Restore schema on metadata driver too - 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) - } + if let savedSchema = self.activeSessions[metaConnectionId]?.currentSchema, + let schemaMetaDriver = metaDriver as? SchemaSwitchable { + try? await schemaMetaDriver.switchSchema(to: savedSchema) } // Restore database on metadata driver too for MSSQL if let savedDatabase = self.activeSessions[metaConnectionId]?.currentDatabase, @@ -694,7 +676,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 +687,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 - - do { - // Attempt to reconnect - try await connectToSession(session.connection) - Self.logger.info("Successfully reconnected SSH tunnel for: \(session.connection.name)") - } catch { - Self.logger.error("Failed to reconnect SSH tunnel: \(error.localizedDescription)") + 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 } @@ -811,10 +800,8 @@ final class DatabaseManager { // Query the actual constraint name from pg_constraint let escapedTable = tableName.replacingOccurrences(of: "'", with: "''") let schema: String - if let pgDriver = driver as? PostgreSQLDriver { - schema = pgDriver.escapedSchema - } else if let rsDriver = driver as? RedshiftDriver { - schema = rsDriver.escapedSchema + if let schemaDriver = driver as? SchemaSwitchable { + schema = schemaDriver.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/MSSQLDriver.swift b/TablePro/Core/Database/MSSQLDriver.swift index 57b4f622..c8d0db9a 100644 --- a/TablePro/Core/Database/MSSQLDriver.swift +++ b/TablePro/Core/Database/MSSQLDriver.swift @@ -11,7 +11,7 @@ import OSLog private let logger = Logger(subsystem: "com.TablePro", category: "MSSQLDriver") /// SQL Server database driver using FreeTDS db-lib -final class MSSQLDriver: DatabaseDriver { +final class MSSQLDriver: DatabaseDriver, SchemaSwitchable { let connection: DatabaseConnection private(set) var status: ConnectionStatus = .disconnected diff --git a/TablePro/Core/Database/OracleConnection.swift b/TablePro/Core/Database/OracleConnection.swift new file mode 100644 index 00000000..d82fdb81 --- /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 + let capturedEnv = envHandle + let capturedErr = errHandle + let capturedSvc = svcHandle + let capturedSrv = srvHandle + let capturedSes = sesHandle + envHandle = nil + errHandle = nil + svcHandle = nil + srvHandle = nil + sesHandle = nil + lock.unlock() + + guard wasConnected else { return } + + queue.async { + if let ses = capturedSes, let svc = capturedSvc, let err = capturedErr { + _ = OCISessionEnd(svc, err, ses, UInt32(OCI_DEFAULT)) + } + if let srv = capturedSrv, let err = capturedErr { + _ = OCIServerDetach(srv, err, UInt32(OCI_DEFAULT)) + } + if let ses = capturedSes { _ = OCIHandleFree(ses, UInt32(OCI_HTYPE_SESSION)) } + if let svc = capturedSvc { _ = OCIHandleFree(svc, UInt32(OCI_HTYPE_SVCCTX)) } + if let srv = capturedSrv { _ = OCIHandleFree(srv, UInt32(OCI_HTYPE_SERVER)) } + if let err = capturedErr { _ = OCIHandleFree(err, UInt32(OCI_HTYPE_ERROR)) } + if let env = capturedEnv { _ = OCIHandleFree(env, UInt32(OCI_HTYPE_ENV)) } + } + } + + // 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: [UnsafeMutableBufferPointer] = [] + 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 { + var paramRaw: UnsafeMutableRawPointer? + _ = OCIParamGet(stmt, UInt32(OCI_HTYPE_STMT), err, ¶mRaw, UInt32(i)) + + 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) + + var dataType: UInt16 = 0 + _ = OCIAttrGet( + paramRaw, UInt32(OCI_DTYPE_PARAM), + &dataType, nil, UInt32(OCI_ATTR_DATA_TYPE), err + ) + typeNames.append(oracleTypeName(Int32(dataType))) + + // Heap-allocate buffer so the pointer stays valid through fetch + let buf = UnsafeMutableBufferPointer.allocate(capacity: bufSize) + buf.initialize(repeating: 0) + buffers.append(buf) + } + + defer { + for buf in buffers { buf.deallocate() } + } + + for i in 0.. 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..286c123a --- /dev/null +++ b/TablePro/Core/Database/OracleDriver.swift @@ -0,0 +1,517 @@ +// +// 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, SchemaSwitchable { + 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.oracleServiceName ?? 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 { + try await executeWithReconnect(query: query, isRetry: false) + } + + func testConnection() async throws -> Bool { + try await connect() + let isConnected = status == .connected + disconnect() + return isConnected + } + + 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 executeWithReconnect(query: String, isRetry: Bool) async throws -> QueryResult { + guard let conn = oracleConn else { + throw DatabaseError.connectionFailed("Not connected to Oracle") + } + let startTime = Date() + do { + let result = try await conn.executeQuery(query) + return mapToQueryResult(result, executionTime: Date().timeIntervalSince(startTime)) + } catch let error as NSError where !isRetry && isConnectionLostError(error) { + try await reconnect() + return try await executeWithReconnect(query: query, isRetry: true) + } catch { + throw DatabaseError.queryFailed(error.localizedDescription) + } + } + + private func isConnectionLostError(_ error: NSError) -> Bool { + let msg = error.localizedDescription.lowercased() + return msg.contains("connection") && + (msg.contains("lost") || msg.contains("closed") || + msg.contains("no connection") || msg.contains("not connected")) + } + + private func reconnect() async throws { + oracleConn?.disconnect() + oracleConn = nil + status = .connecting + try await connect() + } + + private func mapToQueryResult(_ oracleResult: OracleQueryResult, executionTime: TimeInterval) -> QueryResult { + let columnTypes = oracleResult.columnTypeNames.map { rawType in + ColumnType(fromOracleType: rawType) + } + return QueryResult( + columns: oracleResult.columns, + columnTypes: columnTypes, + rows: oracleResult.rows, + rowsAffected: oracleResult.affectedRows, + executionTime: executionTime, + error: nil + ) + } +} diff --git a/TablePro/Core/Database/PostgreSQLDriver.swift b/TablePro/Core/Database/PostgreSQLDriver.swift index 3d764d0c..26d7bfef 100644 --- a/TablePro/Core/Database/PostgreSQLDriver.swift +++ b/TablePro/Core/Database/PostgreSQLDriver.swift @@ -8,7 +8,7 @@ import Foundation /// PostgreSQL database driver using libpq native library -final class PostgreSQLDriver: DatabaseDriver { +final class PostgreSQLDriver: DatabaseDriver, SchemaSwitchable { let connection: DatabaseConnection private(set) var status: ConnectionStatus = .disconnected diff --git a/TablePro/Core/Database/RedshiftDriver.swift b/TablePro/Core/Database/RedshiftDriver.swift index 64a52f52..a40b8377 100644 --- a/TablePro/Core/Database/RedshiftDriver.swift +++ b/TablePro/Core/Database/RedshiftDriver.swift @@ -9,7 +9,7 @@ import Foundation import os /// Amazon Redshift database driver using libpq native library -final class RedshiftDriver: DatabaseDriver { +final class RedshiftDriver: DatabaseDriver, SchemaSwitchable { let connection: DatabaseConnection private(set) var status: ConnectionStatus = .disconnected 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/ColumnType.swift b/TablePro/Core/Services/ColumnType.swift index 3f1c373d..94687930 100644 --- a/TablePro/Core/Services/ColumnType.swift +++ b/TablePro/Core/Services/ColumnType.swift @@ -182,6 +182,38 @@ enum ColumnType: Equatable { } } + // MARK: - Oracle Type Mapping + + init(fromOracleType typeName: String?) { + guard let type = typeName?.lowercased() else { + self = .text(rawType: typeName) + return + } + + switch type { + case "integer", "smallint": + self = .integer(rawType: typeName) + case "number": + self = .decimal(rawType: typeName) + case "float", "binary_float", "binary_double": + self = .decimal(rawType: typeName) + case "date": + self = .date(rawType: typeName) + case "timestamp", "timestamp with time zone", "timestamp with local time zone": + self = .timestamp(rawType: typeName) + case "interval year to month", "interval day to second": + self = .text(rawType: typeName) + case "blob", "raw", "long raw", "bfile": + self = .blob(rawType: typeName) + case "clob", "nclob", "long": + self = .text(rawType: typeName) + case "rowid": + self = .text(rawType: typeName) + default: + self = .text(rawType: typeName) + } + } + // MARK: - MongoDB BSON Type Mapping /// Initialize from BSON type integer code 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/SessionStateFactory.swift b/TablePro/Core/Services/SessionStateFactory.swift new file mode 100644 index 00000000..a1db9cbf --- /dev/null +++ b/TablePro/Core/Services/SessionStateFactory.swift @@ -0,0 +1,92 @@ +// +// SessionStateFactory.swift +// TablePro +// +// Factory for creating session state objects used by MainContentView. +// Extracted from MainContentView.init to enable testability. +// + +import Foundation + +@MainActor +enum SessionStateFactory { + struct SessionState { + let tabManager: QueryTabManager + let changeManager: DataChangeManager + let filterStateManager: FilterStateManager + let toolbarState: ConnectionToolbarState + let coordinator: MainContentCoordinator + } + + static func create( + connection: DatabaseConnection, + payload: EditorTabPayload? + ) -> SessionState { + let tabMgr = QueryTabManager() + let changeMgr = DataChangeManager() + let filterMgr = FilterStateManager() + let toolbarSt = ConnectionToolbarState(connection: connection) + + // Eagerly populate version + state from existing session to avoid flash + if let session = DatabaseManager.shared.session(for: connection.id) { + toolbarSt.updateConnectionState(from: session.status) + if let driver = session.driver { + toolbarSt.databaseVersion = driver.serverVersion + } + } else if let driver = DatabaseManager.shared.driver(for: connection.id) { + toolbarSt.connectionState = .connected + toolbarSt.databaseVersion = driver.serverVersion + } + toolbarSt.hasCompletedSetup = true + + // Redis: set initial database name eagerly to avoid toolbar flash + if connection.type == .redis { + let dbIndex = connection.redisDatabase ?? Int(connection.database) ?? 0 + toolbarSt.databaseName = String(dbIndex) + } + + // Initialize single tab based on payload + if let payload, !payload.isConnectionOnly { + switch payload.tabType { + case .table: + if let tableName = payload.tableName { + tabMgr.addTableTab( + tableName: tableName, + databaseType: connection.type, + databaseName: payload.databaseName ?? connection.database + ) + if let index = tabMgr.selectedTabIndex { + tabMgr.tabs[index].isView = payload.isView + tabMgr.tabs[index].isEditable = !payload.isView + if payload.showStructure { + tabMgr.tabs[index].showStructure = true + } + } + } else { + tabMgr.addTab(databaseName: payload.databaseName ?? connection.database) + } + case .query: + tabMgr.addTab( + initialQuery: payload.initialQuery, + databaseName: payload.databaseName ?? connection.database + ) + } + } + + let coord = MainContentCoordinator( + connection: connection, + tabManager: tabMgr, + changeManager: changeMgr, + filterStateManager: filterMgr, + toolbarState: toolbarSt + ) + + return SessionState( + tabManager: tabMgr, + changeManager: changeMgr, + filterStateManager: filterMgr, + toolbarState: toolbarSt, + coordinator: coord + ) + } +} diff --git a/TablePro/Core/Services/TableQueryBuilder.swift b/TablePro/Core/Services/TableQueryBuilder.swift index 5fe4e26f..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/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/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/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/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/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/ViewModels/DatabaseSwitcherViewModel.swift b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift index ee267da4..d88c6bd3 100644 --- a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift +++ b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift @@ -33,7 +33,7 @@ class DatabaseSwitcherViewModel { var showPreview = false var mode: Mode - /// Whether we're switching schemas (Redshift or PostgreSQL in schema mode) + /// Whether we're switching schemas (Redshift, PostgreSQL, or CockroachDB in schema mode) var isSchemaMode: Bool { mode == .schema } // MARK: - Dependencies @@ -77,7 +77,7 @@ class DatabaseSwitcherViewModel { self.currentDatabase = currentDatabase self.currentSchema = currentSchema self.databaseType = databaseType - self.mode = databaseType == .redshift ? .schema : .database + self.mode = (databaseType == .redshift || databaseType == .cockroachdb) ? .schema : .database self.recentDatabases = UserDefaults.standard.recentDatabases(for: connectionId) } @@ -180,6 +180,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: @@ -188,6 +190,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 4404574c..afb9fd8b 100644 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift @@ -62,7 +62,7 @@ struct DatabaseSwitcherSheet: View { .padding(.vertical, 12) // Databases / Schemas toggle (PostgreSQL only) - if databaseType == .postgresql { + if databaseType == .postgresql || databaseType == .cockroachdb { Picker("", selection: $viewModel.mode) { Text(String(localized: "Databases")) .tag(DatabaseSwitcherViewModel.Mode.database) @@ -434,7 +434,7 @@ struct DatabaseSwitcherSheet: View { viewModel.trackAccess(database: database) // Call appropriate callback - if viewModel.isSchemaMode, databaseType == .postgresql, let onSelectSchema { + if viewModel.isSchemaMode, (databaseType == .postgresql || databaseType == .cockroachdb), let onSelectSchema { onSelectSchema(database) } else { onSelect(database) 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 d6159b3f..8c812aad 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -130,8 +130,8 @@ extension MainContentCoordinator { switch connection.type { case .postgresql: let schema: String - if let pgDriver = DatabaseManager.shared.driver(for: connectionId) as? PostgreSQLDriver { - schema = pgDriver.escapedSchema + if let schemaDriver = DatabaseManager.shared.driver(for: connectionId) as? SchemaSwitchable { + schema = schemaDriver.escapedSchema } else { schema = "public" } @@ -151,8 +151,8 @@ extension MainContentCoordinator { """ case .redshift: let schema: String - if let rsDriver = DatabaseManager.shared.driver(for: connectionId) as? RedshiftDriver { - schema = rsDriver.escapedSchema + if let schemaDriver = DatabaseManager.shared.driver(for: connectionId) as? SchemaSwitchable { + schema = schemaDriver.escapedSchema } else { schema = "public" } @@ -170,6 +170,27 @@ extension MainContentCoordinator { WHERE schema = '\(schema)' ORDER BY "table" """ + case .cockroachdb: + let schema: String + if let schemaDriver = DatabaseManager.shared.driver(for: connectionId) as? SchemaSwitchable { + schema = schemaDriver.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 schemaDriver = DatabaseManager.shared.driver(for: connectionId) as? SchemaSwitchable { + schema = schemaDriver.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})", @@ -320,17 +358,12 @@ extension MainContentCoordinator { await loadSchema() NotificationCenter.default.post(name: .refreshData, object: nil) - } else if connection.type == .redshift { - // Redshift: switch schema - if let rsDriver = driver as? RedshiftDriver { - try await rsDriver.switchSchema(to: database) - } else { - return - } + } else if connection.type == .redshift || connection.type == .cockroachdb { + guard let schemaDriver = driver as? SchemaSwitchable else { return } + try await schemaDriver.switchSchema(to: database) - // Also switch metadata driver's schema - if let rsMeta = DatabaseManager.shared.metadataDriver(for: connectionId) as? RedshiftDriver { - try? await rsMeta.switchSchema(to: database) + if let schemaMeta = DatabaseManager.shared.metadataDriver(for: connectionId) as? SchemaSwitchable { + try? await schemaMeta.switchSchema(to: database) } // Update session @@ -353,6 +386,28 @@ 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 { + guard let schemaDriver = driver as? SchemaSwitchable else { return } + try await schemaDriver.switchSchema(to: database) + + if let schemaMeta = DatabaseManager.shared.metadataDriver(for: connectionId) as? SchemaSwitchable { + try? await schemaMeta.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 { @@ -445,18 +500,15 @@ extension MainContentCoordinator { /// Switch to a different PostgreSQL schema (used for URL-based schema selection) func switchSchema(to schema: String) async { - guard connection.type == .postgresql else { return } + guard connection.type == .postgresql || connection.type == .cockroachdb else { return } guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return } do { - if let pgDriver = driver as? PostgreSQLDriver { - try await pgDriver.switchSchema(to: schema) - } else { - return - } + guard let schemaDriver = driver as? SchemaSwitchable else { return } + try await schemaDriver.switchSchema(to: schema) - if let pgMeta = DatabaseManager.shared.metadataDriver(for: connectionId) as? PostgreSQLDriver { - try? await pgMeta.switchSchema(to: schema) + if let schemaMeta = DatabaseManager.shared.metadataDriver(for: connectionId) as? SchemaSwitchable { + try? await schemaMeta.switchSchema(to: schema) } DatabaseManager.shared.updateSession(connectionId) { session in 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 075249d4..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. @@ -193,6 +193,9 @@ final class MainContentCoordinator { } querySortCache.removeAll() + tabManager.tabs.removeAll() + tabManager.selectedTabId = nil + Self.releaseSchemaProvider(for: connection.id) Self.purgeUnusedSchemaProviders() } @@ -427,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) @@ -1187,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/MainContentView.swift b/TablePro/Views/MainContentView.swift index 88788594..f1c4a4ac 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)) @@ -282,6 +224,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 } @@ -994,6 +937,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, @@ -1004,7 +951,12 @@ private struct FocusedCommandActionsModifier: ViewModifier { pendingDeletes: .constant([]), tableOperationOptions: .constant([:]), inspectorContext: .constant(.empty), - rightPanelState: RightPanelState() + rightPanelState: RightPanelState(), + tabManager: state.tabManager, + changeManager: state.changeManager, + filterStateManager: state.filterStateManager, + toolbarState: state.toolbarState, + coordinator: state.coordinator ) .frame(width: 1_000, height: 600) } diff --git a/TablePro/Views/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: diff --git a/TableProTests/Models/RightPanelStateTests.swift b/TableProTests/Models/RightPanelStateTests.swift index a1b89aaf..a9c8b34d 100644 --- a/TableProTests/Models/RightPanelStateTests.swift +++ b/TableProTests/Models/RightPanelStateTests.swift @@ -53,4 +53,28 @@ struct RightPanelStateTests { #expect(state2.isPresented == true) UserDefaults.standard.removeObject(forKey: Self.key) } + + @Test("teardown nils schemaProvider on aiViewModel") + @MainActor + func teardown_nilsSchemaProvider() { + let state = RightPanelState() + state.aiViewModel.schemaProvider = SQLSchemaProvider() + #expect(state.aiViewModel.schemaProvider != nil) + + state.teardown() + + #expect(state.aiViewModel.schemaProvider == nil) + } + + @Test("teardown nils onSave closure") + @MainActor + func teardown_nilsOnSave() { + let state = RightPanelState() + state.onSave = { } + #expect(state.onSave != nil) + + state.teardown() + + #expect(state.onSave == nil) + } } diff --git a/TableProTests/Views/Main/SessionStateFactoryTests.swift b/TableProTests/Views/Main/SessionStateFactoryTests.swift new file mode 100644 index 00000000..5169ebd2 --- /dev/null +++ b/TableProTests/Views/Main/SessionStateFactoryTests.swift @@ -0,0 +1,173 @@ +// +// SessionStateFactoryTests.swift +// TableProTests +// +// Tests for SessionStateFactory, validating session state creation logic +// extracted from MainContentView.init. +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("SessionStateFactory") +struct SessionStateFactoryTests { + // MARK: - Helpers + + private func makePayload( + connectionId: UUID = UUID(), + tabType: TabType = .query, + tableName: String? = nil, + databaseName: String? = nil, + initialQuery: String? = nil, + isView: Bool = false, + showStructure: Bool = false + ) -> EditorTabPayload { + EditorTabPayload( + connectionId: connectionId, + tabType: tabType, + tableName: tableName, + databaseName: databaseName, + initialQuery: initialQuery, + isView: isView, + showStructure: showStructure + ) + } + + // MARK: - Tests + + @Test("Payload with tableName creates a table tab") + @MainActor + func payloadWithTableName_createsTableTab() { + let conn = TestFixtures.makeConnection() + let payload = makePayload( + connectionId: conn.id, + tabType: .table, + tableName: "users" + ) + + let state = SessionStateFactory.create(connection: conn, payload: payload) + + #expect(state.tabManager.tabs.count == 1) + #expect(state.tabManager.tabs.first?.tableName == "users") + #expect(state.tabManager.tabs.first?.tabType == .table) + } + + @Test("Payload with initialQuery creates a query tab with that text") + @MainActor + func payloadWithQuery_createsQueryTab() { + let conn = TestFixtures.makeConnection() + let query = "SELECT * FROM orders" + let payload = makePayload( + connectionId: conn.id, + tabType: .query, + initialQuery: query + ) + + let state = SessionStateFactory.create(connection: conn, payload: payload) + + #expect(state.tabManager.tabs.count == 1) + #expect(state.tabManager.tabs.first?.query == query) + #expect(state.tabManager.tabs.first?.tabType == .query) + } + + @Test("Payload with showStructure sets showStructure on the tab") + @MainActor + func payloadWithStructure_setsShowStructure() { + let conn = TestFixtures.makeConnection() + let payload = makePayload( + connectionId: conn.id, + tabType: .table, + tableName: "users", + showStructure: true + ) + + let state = SessionStateFactory.create(connection: conn, payload: payload) + + guard let tab = state.tabManager.tabs.first else { + Issue.record("Expected at least one tab") + return + } + #expect(tab.showStructure == true) + } + + @Test("Payload with isView sets isView and clears isEditable") + @MainActor + func payloadWithView_setsIsViewAndNotEditable() { + let conn = TestFixtures.makeConnection() + let payload = makePayload( + connectionId: conn.id, + tabType: .table, + tableName: "user_view", + isView: true + ) + + let state = SessionStateFactory.create(connection: conn, payload: payload) + + guard let tab = state.tabManager.tabs.first else { + Issue.record("Expected at least one tab") + return + } + #expect(tab.isView == true) + #expect(tab.isEditable == false) + } + + @Test("Nil payload creates empty tab manager") + @MainActor + func nilPayload_createsEmptyTabManager() { + let conn = TestFixtures.makeConnection() + + let state = SessionStateFactory.create(connection: conn, payload: nil) + + #expect(state.tabManager.tabs.isEmpty) + } + + @Test("Connection-only payload creates empty tab manager") + @MainActor + func connectionOnlyPayload_createsEmptyTabManager() { + let conn = TestFixtures.makeConnection() + // isConnectionOnly is true when tabType == .query, tableName == nil, initialQuery == nil + let payload = makePayload(connectionId: conn.id, tabType: .query) + + let state = SessionStateFactory.create(connection: conn, payload: payload) + + #expect(state.tabManager.tabs.isEmpty) + } + + @Test("Factory is idempotent: two calls produce fresh but equivalent instances") + @MainActor + func factoryIsIdempotent() { + let conn = TestFixtures.makeConnection() + let payload = makePayload( + connectionId: conn.id, + tabType: .table, + tableName: "products" + ) + + let state1 = SessionStateFactory.create(connection: conn, payload: payload) + let state2 = SessionStateFactory.create(connection: conn, payload: payload) + + // Different instances + #expect(state1.tabManager !== state2.tabManager) + #expect(state1.coordinator !== state2.coordinator) + + // Equivalent content + #expect(state1.tabManager.tabs.count == state2.tabManager.tabs.count) + #expect(state1.tabManager.tabs.first?.tableName == state2.tabManager.tabs.first?.tableName) + } + + @Test("Coordinator receives the factory's tabManager") + @MainActor + func coordinatorReceivesCorrectDependencies() { + let conn = TestFixtures.makeConnection() + let payload = makePayload( + connectionId: conn.id, + tabType: .table, + tableName: "items" + ) + + let state = SessionStateFactory.create(connection: conn, payload: payload) + + #expect(state.coordinator.tabManager === state.tabManager) + } +} diff --git a/docs/databases/cockroachdb.mdx b/docs/databases/cockroachdb.mdx new file mode 100644 index 00000000..bd2d9faf --- /dev/null +++ b/docs/databases/cockroachdb.mdx @@ -0,0 +1,262 @@ +--- +title: CockroachDB +description: Connect to CockroachDB distributed SQL databases with TablePro +--- + +# CockroachDB Connections + +TablePro supports CockroachDB, a distributed SQL database that uses the PostgreSQL wire protocol. Connections work through the same libpq driver with CockroachDB-specific metadata queries for cluster topology, ranges, and internal tables. + +## Quick Setup + + + + Click **New Connection** from the Welcome screen or **File** > **New Connection** + + + Choose **CockroachDB** from the database type selector + + + Fill in your host, port, username, password, and database name + + + Click **Test Connection**, then **Create** + + + +## Connection Settings + +### Required Fields + +| Field | Description | Default | +|-------|-------------|---------| +| **Name** | Connection identifier | - | +| **Host** | CockroachDB node or load balancer address | - | +| **Port** | CockroachDB SQL port | `26257` | +| **Username** | CockroachDB user | - | +| **Password** | User password | - | +| **Database** | Database name to connect to | `defaultdb` | + + +CockroachDB creates a `defaultdb` database on every new cluster. You can connect to it immediately or specify a different database. + + +## Example Configurations + +### Local CockroachDB + +``` +Name: Local CockroachDB +Host: localhost +Port: 26257 +Username: root +Password: (empty for insecure mode) +Database: defaultdb +``` + +### CockroachDB Cloud Serverless + +``` +Name: Cloud Serverless +Host: free-tier.gcp-us-central1.cockroachlabs.cloud +Port: 26257 +Username: your_user +Password: (your password) +Database: defaultdb +``` + + +For CockroachDB Cloud Serverless, the connection string includes a cluster routing parameter. Find your connection details in the CockroachDB Cloud Console under your cluster's **Connect** dialog. The host typically includes the cluster identifier. + + +### CockroachDB Cloud Dedicated + +``` +Name: Cloud Dedicated +Host: my-cluster-abc.cockroachlabs.cloud +Port: 26257 +Username: admin_user +Password: (your password) +Database: defaultdb +``` + +## Features + +### Schema Support + +CockroachDB organizes tables into schemas, just like PostgreSQL. TablePro displays: + +1. All schemas accessible to your user +2. Tables within each schema +3. The search path order + +### Schema Switching + +Switch between CockroachDB 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 + + +The default schema is `public`. System schemas like `pg_catalog`, `information_schema`, and `crdb_internal` are filtered from the list. + + +### Table Browsing and Data Viewing + +Browse tables, views, and materialized views. The data grid supports pagination for large tables. CockroachDB-specific metadata is available in the table info panel, including range distribution and zone configurations. + +### Query Execution + +Execute queries with CockroachDB SQL syntax support. The SQL editor provides syntax highlighting for CockroachDB-specific functions and keywords. + +```sql +-- Time travel queries with AS OF SYSTEM TIME +SELECT * FROM orders AS OF SYSTEM TIME '-5m'; + +-- Follower reads for low-latency reads from the nearest replica +SET TRANSACTION AS OF SYSTEM TIME follower_read_timestamp(); +SELECT * FROM users WHERE id = 1; + +-- Show how table data is distributed across ranges +SHOW RANGES FROM TABLE orders; + +-- Import data from local node storage +IMPORT INTO users CSV DATA ('nodelocal://1/users.csv'); + +-- Cluster settings +SHOW CLUSTER SETTING server.time_until_store_dead; +``` + +### 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 DDL (CREATE TABLE statement) for any table, including CockroachDB-specific clauses like column families, interleaved tables, and hash-sharded index definitions. + +### Index Browsing + +CockroachDB supports real indexes, unlike columnar warehouses. TablePro displays all index types in the Structure tab: + +- **B-tree indexes** (default) +- **Inverted indexes** (for JSONB, ARRAY, and full-text search columns) +- **Hash-sharded indexes** (for sequential key write distribution) + +### Foreign Key Navigation + +CockroachDB enforces foreign key constraints. TablePro displays these relationships and allows navigation between related tables by clicking on foreign key values. + +## 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 requires SSL for all connections. Use **Verify CA** or **Verify Identity** mode and provide the CA certificate downloaded from the Cloud Console. + + + +For local development clusters started with `cockroach start --insecure`, SSL is not required. For production deployments, always use **Verify CA** or stricter. + + +## SSH Tunnel Support + +You can connect to CockroachDB through an SSH tunnel if your cluster is in a private network. Configure the SSH tunnel in the connection form's SSH tab, then TablePro routes the CockroachDB connection through the tunnel. + +## Differences from PostgreSQL + +CockroachDB is PostgreSQL wire-compatible but not a drop-in replacement. Key differences: + +| Feature | CockroachDB | PostgreSQL | +|---------|-------------|------------| +| Default port | `26257` | `5432` | +| Default database | `defaultdb` | `postgres` | +| Storage model | Distributed key-value (RocksDB/Pebble) | Single-node row storage | +| Transactions | Serializable by default | Read committed by default | +| `ENUM` types | Supported | Supported | +| `SERIAL` | Maps to `unique_rowid()`, not a sequence | Auto-incrementing integer via sequence | +| Sequences | Supported (but non-contiguous due to distribution) | Contiguous within a session | +| Triggers | Limited support (v23.1+) | Full trigger support | +| Stored procedures | Not supported (use UDFs instead) | Full PL/pgSQL support | +| Extensions | Not supported (`pg_trgm`, `PostGIS`, etc.) | Rich extension ecosystem | +| System catalog | `crdb_internal.*` tables | `pg_stat_*` views | +| `EXPLAIN` output | Distributed execution plan with node info | Single-node plan | +| Temp tables | Supported (session-scoped) | Supported | +| `COPY` | Supported for import/export | Supported | + +## Troubleshooting + +### Connection Refused + +**Symptoms**: "Connection refused" or timeout + +**Common causes**: + +1. **Node not running**: Verify the CockroachDB process is running with `cockroach node status`. +2. **Wrong port**: The default SQL port is 26257, not 5432. The admin UI runs on 8080. +3. **Firewall rules**: Ensure port 26257 is open in your network security group or firewall. +4. **Load balancer**: If connecting through a load balancer, verify it's configured to forward to port 26257. + +### Authentication Failed + +**Symptoms**: "password authentication failed" + +**Solutions**: + +1. Verify the username and password. For local insecure clusters, the `root` user has no password. +2. Check that the user exists: `SHOW USERS` in a SQL shell. +3. For CockroachDB Cloud, ensure you're using the credentials from the Cloud Console, not your account password. + +### SSL Certificate Errors + +**Symptoms**: "certificate verify failed" or "SSL error" + +**Solutions**: + +1. For CockroachDB Cloud, download the CA certificate from the Cloud Console and set it in the connection form's SSL section. +2. For self-hosted clusters, point to the `ca.crt` file from your CockroachDB certificate directory (usually `~/.cockroach-certs/`). +3. If testing locally with `--insecure` mode, set SSL to **Disabled**. + +### Query Performance + +CockroachDB distributes data across nodes. If queries are slow: + +1. Run `EXPLAIN ANALYZE` to see the distributed execution plan and identify which nodes are involved +2. Check for full table scans on large tables with `SHOW STATISTICS FOR TABLE your_table` +3. Review index usage: `SELECT * FROM crdb_internal.index_usage_statistics WHERE table_name = 'your_table'` +4. Ensure your primary key choice distributes writes evenly. UUID or hash-sharded keys prevent hotspots on a single range. + +## Next Steps + + + + Connect to CockroachDB clusters in private networks + + + Write and execute CockroachDB queries + + + Import and export CockroachDB data + + + View indexes, foreign keys, and column definitions + + diff --git a/docs/databases/connection-urls.mdx b/docs/databases/connection-urls.mdx index 0a28d528..d002c2a8 100644 --- a/docs/databases/connection-urls.mdx +++ b/docs/databases/connection-urls.mdx @@ -21,6 +21,7 @@ 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) | diff --git a/docs/databases/oracle.mdx b/docs/databases/oracle.mdx new file mode 100644 index 00000000..031a094e --- /dev/null +++ b/docs/databases/oracle.mdx @@ -0,0 +1,261 @@ +--- +title: Oracle Database +description: Connect to Oracle Database with TablePro +--- + +# Oracle Database Connections + +TablePro connects to Oracle Database 12c+ using the OCI (Oracle Call Interface) driver. You need Oracle Instant Client installed on your Mac for the connection to work. + +## Prerequisites + +Install Oracle Instant Client before connecting: + +1. Download [Oracle Instant Client](https://www.oracle.com/database/technologies/instant-client/macos-arm64-downloads.html) for macOS (Basic or Basic Light package) +2. Extract to a directory like `~/instantclient` +3. Set the `ORACLE_HOME` environment variable or place the libraries where the system can find them + + +Without Oracle Instant Client installed, TablePro cannot establish Oracle connections. The OCI driver depends on the Instant Client shared libraries. + + +## Quick Setup + + + + Click **New Connection** from the Welcome screen or **File** > **New Connection** + + + Choose **Oracle** from the database type selector + + + Fill in your host, port, service name, username, and password + + + Click **Test Connection**, then **Create** + + + +## Connection Settings + +### Required Fields + +| Field | Description | Default | +|-------|-------------|---------| +| **Name** | Connection identifier | - | +| **Host** | Oracle server hostname or IP | - | +| **Port** | Oracle listener port | `1521` | +| **Username** | Oracle user | - | +| **Password** | User password | - | +| **Service Name** | Oracle service name (not SID) | - | + +The connection string format is `//host:port/service_name`. + + +TablePro uses service names, not SIDs. If you only have a SID, check your `tnsnames.ora` for the corresponding service name, or ask your DBA. Most modern Oracle installations use service names by default. + + + +You can also connect directly by opening an `oracle://` URL from a browser or terminal. See [Connection URLs Reference](/databases/connection-urls) for details. + + +## Example Configurations + +### Local Oracle XE + +``` +Name: Local Oracle XE +Host: localhost +Port: 1521 +Username: system +Password: (your password) +Service Name: XEPDB1 +``` + +### Oracle Cloud (Autonomous Database) + +``` +Name: Oracle Cloud ADB +Host: adb.us-ashburn-1.oraclecloud.com +Port: 1522 +Username: ADMIN +Password: (your password) +Service Name: abc123_mydb_high.adb.oraclecloud.com +``` + + +Oracle Cloud Autonomous Database uses port 1522 and requires a wallet for mTLS connections. Download the wallet from the OCI Console and configure the connection accordingly. + + +### Remote Oracle Database + +``` +Name: Production Oracle +Host: oracle.example.com +Port: 1521 +Username: app_user +Password: (your password) +Service Name: ORCL +``` + +## Features + +### Schema Support + +Oracle schemas map directly to Oracle users. Each user owns a schema with the same name. TablePro displays: + +1. All schemas accessible to your user +2. Tables, views, and other objects within each schema +3. The current schema context + +### Schema Switching + +Switch between Oracle 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 + + +System schemas like SYS, SYSTEM, OUTLN, DBSNMP, APPQOSSYS, WMSYS, and XDB are filtered from the schema list by default. + + +### Table and View Browsing + +Browse tables, views, materialized views, and synonyms. The data grid supports pagination using Oracle's `OFFSET`/`FETCH` syntax for large tables. + +### Query Execution + +Execute queries with Oracle SQL and PL/SQL support. The SQL editor provides syntax highlighting for Oracle-specific functions and keywords. + +```sql +-- Basic pagination with OFFSET/FETCH (12c+) +SELECT * FROM employees +ORDER BY employee_id +OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY; + +-- Oracle's DUAL table for expressions +SELECT SYSDATE, USER, SYS_CONTEXT('USERENV', 'DB_NAME') FROM DUAL; + +-- PL/SQL anonymous block +BEGIN + DBMS_OUTPUT.PUT_LINE('Current schema: ' || SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA')); +END; +/ + +-- Hierarchical queries with CONNECT BY +SELECT employee_id, manager_id, LEVEL +FROM employees +START WITH manager_id IS NULL +CONNECT BY PRIOR employee_id = manager_id; + +-- Flashback query +SELECT * FROM orders AS OF TIMESTAMP (SYSTIMESTAMP - INTERVAL '1' HOUR); +``` + +### DDL Generation + +View the DDL for any table using Oracle's `DBMS_METADATA` package. This generates the complete CREATE TABLE statement including storage clauses, tablespace assignments, constraints, and index definitions. + +### 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 Oracle tables. + +### Index Browsing + +TablePro displays all index types in the Structure tab: + +- **B-tree indexes** (default) +- **Bitmap indexes** +- **Function-based indexes** +- **Composite indexes** +- **Unique indexes** + +### Foreign Key Navigation + +Oracle enforces foreign key constraints with full referential integrity. TablePro displays these relationships and allows navigation between related tables by clicking on foreign key values. + +## SSL/TLS + +Oracle connections support SSL/TLS encryption through Oracle Net Services: + +| SSL Mode | Description | +|----------|-------------| +| **Disabled** | No SSL encryption | +| **Required** | Require SSL, but don't verify certificates | +| **Verify CA** | Require SSL and verify the server certificate against a CA | + + +For Oracle Cloud Autonomous Database, download the connection wallet from the OCI Console. The wallet contains the certificates and connection descriptors needed for mTLS authentication. + + +## SSH Tunnel Support + +You can connect to Oracle through an SSH tunnel if your database server is in a private network. Configure the SSH tunnel in the connection form's SSH tab, then TablePro routes the Oracle connection through the tunnel. + +## Troubleshooting + +### OCI Driver Not Found + +**Symptoms**: "OCI library not found" or "Cannot load OCI shared library" + +**Solutions**: + +1. Verify Oracle Instant Client is installed and the shared libraries are accessible. +2. Check that `ORACLE_HOME` or `DYLD_LIBRARY_PATH` points to the Instant Client directory. +3. On Apple Silicon Macs, make sure you downloaded the arm64 version of Instant Client. + +### Connection Refused + +**Symptoms**: "ORA-12541: TNS:no listener" or connection timeout + +**Common causes**: + +1. **Listener not running**: Verify the Oracle listener is running with `lsnrctl status` on the server. +2. **Wrong port**: The default listener port is 1521. Check `listener.ora` for custom ports. +3. **Firewall rules**: Ensure port 1521 is open in your network security group or firewall. +4. **Service name mismatch**: Verify the service name with `lsnrctl services` on the server. + +### Authentication Failed + +**Symptoms**: "ORA-01017: invalid username/password; logon denied" + +**Solutions**: + +1. Oracle passwords are case-sensitive (12c+ with `SEC_CASE_SENSITIVE_LOGON=TRUE`). +2. Check if the account is locked: `SELECT account_status FROM dba_users WHERE username = 'YOUR_USER'`. +3. Verify the user exists and has CREATE SESSION privilege. + +### ORA-12514: TNS:listener does not currently know of service + +**Solutions**: + +1. Confirm the service name is correct. List registered services with `lsnrctl services`. +2. The database instance may not be registered with the listener yet. Wait for auto-registration or run `ALTER SYSTEM REGISTER`. +3. Check if you're using a SID instead of a service name. TablePro uses service names. + +## Next Steps + + + + Connect to Oracle databases in private networks + + + Write and execute Oracle queries and PL/SQL blocks + + + Import and export Oracle data + + + View indexes, foreign keys, and column definitions + + diff --git a/docs/databases/overview.mdx b/docs/databases/overview.mdx index 25027e98..5292958f 100644 --- a/docs/databases/overview.mdx +++ b/docs/databases/overview.mdx @@ -99,7 +99,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`, and `cockroachdb` as URL schemes on macOS, so the OS routes these URLs directly to the app. **What happens:** @@ -560,6 +560,9 @@ TablePro automatically sets the default port when you select a database type: Redshift data warehouse connections + + CockroachDB distributed SQL connections + Secure connections through SSH diff --git a/docs/docs.json b/docs/docs.json index be3d1b62..59697f5e 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -39,6 +39,8 @@ "databases/mongodb", "databases/redis", "databases/redshift", + "databases/cockroachdb", + "databases/oracle", "databases/mssql", "databases/ssh-tunneling" ] @@ -127,6 +129,8 @@ "vi/databases/mongodb", "vi/databases/redis", "vi/databases/redshift", + "vi/databases/cockroachdb", + "vi/databases/oracle", "vi/databases/mssql", "vi/databases/ssh-tunneling" ] diff --git a/docs/vi/databases/cockroachdb.mdx b/docs/vi/databases/cockroachdb.mdx new file mode 100644 index 00000000..3f98b894 --- /dev/null +++ b/docs/vi/databases/cockroachdb.mdx @@ -0,0 +1,262 @@ +--- +title: CockroachDB +description: Kết nối đến cơ sở dữ liệu phân tán CockroachDB với TablePro +--- + +# Kết nối CockroachDB + +TablePro hỗ trợ CockroachDB, cơ sở dữ liệu SQL phân tán sử dụng giao thức PostgreSQL wire. Các kết nối hoạt động qua cùng driver libpq với các truy vấn metadata riêng cho CockroachDB về topology cluster, ranges và các bảng nội bộ. + +## 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** | Địa chỉ node hoặc load balancer CockroachDB | - | +| **Port** | Cổng SQL CockroachDB | `26257` | +| **Username** | Tên người dùng CockroachDB | - | +| **Password** | Mật khẩu người dùng | - | +| **Database** | Tên database để kết nối | `defaultdb` | + + +CockroachDB tạo database `defaultdb` trên mọi cluster mới. Bạn có thể kết nối đến nó ngay lập tức hoặc chỉ định database khác. + + +## Các cấu hình ví dụ + +### CockroachDB cục bộ + +``` +Name: Local CockroachDB +Host: localhost +Port: 26257 +Username: root +Password: (để trống cho chế độ insecure) +Database: defaultdb +``` + +### CockroachDB Cloud Serverless + +``` +Name: Cloud Serverless +Host: free-tier.gcp-us-central1.cockroachlabs.cloud +Port: 26257 +Username: your_user +Password: (mật khẩu của bạn) +Database: defaultdb +``` + + +Đối với CockroachDB Cloud Serverless, chuỗi kết nối bao gồm tham số định tuyến cluster. Tìm thông tin kết nối trong CockroachDB Cloud Console tại hộp thoại **Connect** của cluster. Host thường bao gồm mã định danh cluster. + + +### CockroachDB Cloud Dedicated + +``` +Name: Cloud Dedicated +Host: my-cluster-abc.cockroachlabs.cloud +Port: 26257 +Username: admin_user +Password: (mật khẩu của bạn) +Database: defaultdb +``` + +## Tính năng + +### Hỗ trợ Schema + +CockroachDB tổ chức các bảng thành schemas, giống như PostgreSQL. TablePro hiển thị: + +1. Tất cả schemas có thể truy cập bởi người dùng của bạn +2. Các bảng trong mỗi schema +3. Thứ tự search path + +### Chuyển đổi Schema + +Chuyển đổi giữa các schema CockroachDB bằng trình chuyển đổi database (**Cmd+K**): + +1. Nhấn **Cmd+K** để mở trình chuyển đổi +2. Chọn schema đích +3. Sidebar, truy vấn và toolbar sẽ cập nhật theo schema đã chọn + + +Schema mặc định là `public`. Các schema hệ thống như `pg_catalog`, `information_schema` và `crdb_internal` được lọc khỏi danh sách. + + +### Duyệt bảng và xem dữ liệu + +Duyệt bảng, views và materialized views. Data grid hỗ trợ phân trang cho các bảng lớn. Metadata riêng của CockroachDB có sẵn trong panel thông tin bảng, bao gồm phân phối ranges và cấu hình zone. + +### Thực thi truy vấn + +Thực thi truy vấn với hỗ trợ cú pháp CockroachDB SQL. SQL editor cung cấp syntax highlighting cho các hàm và từ khóa riêng của CockroachDB. + +```sql +-- Truy vấn du hành thời gian với AS OF SYSTEM TIME +SELECT * FROM orders AS OF SYSTEM TIME '-5m'; + +-- Follower reads cho đọc độ trễ thấp từ replica gần nhất +SET TRANSACTION AS OF SYSTEM TIME follower_read_timestamp(); +SELECT * FROM users WHERE id = 1; + +-- Xem phân phối dữ liệu bảng qua các ranges +SHOW RANGES FROM TABLE orders; + +-- Import dữ liệu từ bộ nhớ node cục bộ +IMPORT INTO users CSV DATA ('nodelocal://1/users.csv'); + +-- Cài đặt cluster +SHOW CLUSTER SETTING server.time_until_store_dead; +``` + +### 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ừ file CSV, JSON, SQL và XLSX vào các bảng CockroachDB. + +### Tạo DDL + +Xem DDL (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 như column families, interleaved tables và định nghĩa hash-sharded index. + +### Duyệt Index + +CockroachDB hỗ trợ index thực sự, khác với các kho dữ liệu dạng cột. TablePro hiển thị tất cả loại index trong tab Structure: + +- **B-tree indexes** (mặc định) +- **Inverted indexes** (cho cột JSONB, ARRAY và full-text search) +- **Hash-sharded indexes** (để phân phối ghi đều cho sequential keys) + +### Điều hướng Foreign Key + +CockroachDB thực thi các ràng buộc foreign key. TablePro hiển thị các mối quan hệ này và cho phép điều hướng giữa các bảng liên quan bằng cách nhấp vào giá trị foreign key. + +## 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ó sẵn, quay lại 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 yêu cầu SSL cho tất cả kết nối. Sử dụng chế độ **Verify CA** hoặc **Verify Identity** và cung cấp chứng chỉ CA tải xuống từ Cloud Console. + + + +Đối với cluster phát triển cục bộ khởi động với `cockroach start --insecure`, SSL không bắt buộc. Đối với triển khai production, luôn sử dụng **Verify CA** hoặc cao hơn. + + +## Hỗ trợ SSH Tunnel + +Bạn có thể kết nối đến CockroachDB qua SSH tunnel nếu cluster của bạn nằm trong mạng private. Cấu hình SSH tunnel trong tab SSH của form kết nối, sau đó TablePro định tuyến kết nối CockroachDB qua tunnel. + +## Khác biệt với PostgreSQL + +CockroachDB tương thích giao thức PostgreSQL wire nhưng không phải bản thay thế hoàn toàn. Các khác biệt chính: + +| Tính năng | CockroachDB | PostgreSQL | +|---------|-------------|------------| +| Cổng mặc định | `26257` | `5432` | +| Database mặc định | `defaultdb` | `postgres` | +| Mô hình lưu trữ | Key-value phân tán (RocksDB/Pebble) | Lưu trữ theo dòng đơn node | +| Transaction | Serializable theo mặc định | Read committed theo mặc định | +| Kiểu `ENUM` | Hỗ trợ | Hỗ trợ | +| `SERIAL` | Ánh xạ sang `unique_rowid()`, không phải sequence | Số tự tăng qua sequence | +| Sequences | Hỗ trợ (nhưng không liên tục do phân tán) | Liên tục trong một phiên | +| Triggers | Hỗ trợ hạn chế (v23.1+) | Hỗ trợ đầy đủ | +| Stored procedures | Không hỗ trợ (dùng UDFs thay thế) | Hỗ trợ đầy đủ PL/pgSQL | +| Extensions | Không hỗ trợ (`pg_trgm`, `PostGIS`, v.v.) | Hệ sinh thái extension phong phú | +| System catalog | Bảng `crdb_internal.*` | Views `pg_stat_*` | +| Kết quả `EXPLAIN` | Kế hoạch thực thi phân tán với thông tin node | Kế hoạch đơn node | +| Bảng tạm | Hỗ trợ (phạm vi phiên) | Hỗ trợ | +| `COPY` | Hỗ trợ cho import/export | Hỗ trợ | + +## Khắc phục sự cố + +### Connection Refused + +**Triệu chứng**: "Connection refused" hoặc timeout + +**Nguyên nhân phổ biến**: + +1. **Node không chạy**: Xác minh tiến trình CockroachDB đang chạy với `cockroach node status`. +2. **Sai port**: Cổng SQL mặc định là 26257, không phải 5432. Admin UI chạy trên cổng 8080. +3. **Quy tắc tường lửa**: Đảm bảo cổng 26257 được mở trong security group hoặc tường lửa mạng. +4. **Load balancer**: Nếu kết nối qua load balancer, xác minh nó được cấu hình chuyển tiếp đến cổng 26257. + +### Authentication Failed + +**Triệu chứng**: "password authentication failed" + +**Giải pháp**: + +1. Xác minh username và password. Đối với cluster insecure cục bộ, người dùng `root` không có mật khẩu. +2. Kiểm tra người dùng tồn tại: `SHOW USERS` trong SQL shell. +3. Đối với CockroachDB Cloud, đảm bảo bạn sử dụng thông tin đăng nhập từ Cloud Console, không phải mật khẩu tài khoản. + +### Lỗi chứng chỉ SSL + +**Triệu chứng**: "certificate verify failed" hoặc "SSL error" + +**Giải pháp**: + +1. Đối với CockroachDB Cloud, tải chứng chỉ CA từ Cloud Console và đặt trong phần SSL của form kết nối. +2. Đối với cluster tự host, trỏ đến file `ca.crt` từ thư mục chứng chỉ CockroachDB (thường là `~/.cockroach-certs/`). +3. Nếu thử nghiệm cục bộ với chế độ `--insecure`, đặt SSL thành **Disabled**. + +### Hiệu năng truy vấn + +CockroachDB phân phối dữ liệu qua các node. Nếu truy vấn chậm: + +1. Chạy `EXPLAIN ANALYZE` để xem kế hoạch thực thi phân tán và xác định các node liên quan +2. Kiểm tra quét toàn bảng trên bảng lớn với `SHOW STATISTICS FOR TABLE your_table` +3. Xem xét sử dụng index: `SELECT * FROM crdb_internal.index_usage_statistics WHERE table_name = 'your_table'` +4. Đảm bảo lựa chọn primary key phân phối ghi đều. UUID hoặc hash-sharded keys ngăn hotspots trên một range. + +## Bước tiếp theo + + + + Kết nối đến cluster CockroachDB trong mạng private + + + Viết và thực thi truy vấn CockroachDB + + + Import và export dữ liệu CockroachDB + + + Xem indexes, foreign keys và định nghĩa cột + + diff --git a/docs/vi/databases/connection-urls.mdx b/docs/vi/databases/connection-urls.mdx index 1387fde2..01008f61 100644 --- a/docs/vi/databases/connection-urls.mdx +++ b/docs/vi/databases/connection-urls.mdx @@ -21,6 +21,7 @@ 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) | diff --git a/docs/vi/databases/oracle.mdx b/docs/vi/databases/oracle.mdx new file mode 100644 index 00000000..7c820e7b --- /dev/null +++ b/docs/vi/databases/oracle.mdx @@ -0,0 +1,261 @@ +--- +title: Oracle Database +description: Kết nối đến Oracle Database với TablePro +--- + +# Kết nối Oracle Database + +TablePro kết nối đến Oracle Database 12c+ sử dụng driver OCI (Oracle Call Interface). Bạn cần cài đặt Oracle Instant Client trên Mac để kết nối hoạt động. + +## Yêu cầu trước + +Cài đặt Oracle Instant Client trước khi kết nối: + +1. Tải [Oracle Instant Client](https://www.oracle.com/database/technologies/instant-client/macos-arm64-downloads.html) cho macOS (gói Basic hoặc Basic Light) +2. Giải nén vào thư mục như `~/instantclient` +3. Đặt biến môi trường `ORACLE_HOME` hoặc đặt thư viện ở nơi hệ thống có thể tìm thấy + + +Nếu không cài đặt Oracle Instant Client, TablePro không thể thiết lập kết nối Oracle. Driver OCI phụ thuộc vào các thư viện chia sẻ của Instant Client. + + +## Thiết lập nhanh + + + + 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, service name, username và password + + + 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 máy chủ Oracle | - | +| **Port** | Cổng listener Oracle | `1521` | +| **Username** | Tên người dùng Oracle | - | +| **Password** | Mật khẩu người dùng | - | +| **Service Name** | Tên service Oracle (không phải SID) | - | + +Định dạng chuỗi kết nối là `//host:port/service_name`. + + +TablePro sử dụng service name, không phải SID. Nếu bạn chỉ có SID, kiểm tra file `tnsnames.ora` để tìm service name tương ứng, hoặc hỏi DBA. Hầu hết các bản cài đặt Oracle hiện đại sử dụng service name theo mặc định. + + + +Bạn cũng có thể kết nối trực tiếp bằng cách mở URL `oracle://` từ trình duyệt hoặc terminal. Xem [Tham chiếu URL kết nối](/vi/databases/connection-urls) để biết chi tiết. + + +## Các cấu hình ví dụ + +### Oracle XE cục bộ + +``` +Name: Local Oracle XE +Host: localhost +Port: 1521 +Username: system +Password: (mật khẩu của bạn) +Service Name: XEPDB1 +``` + +### Oracle Cloud (Autonomous Database) + +``` +Name: Oracle Cloud ADB +Host: adb.us-ashburn-1.oraclecloud.com +Port: 1522 +Username: ADMIN +Password: (mật khẩu của bạn) +Service Name: abc123_mydb_high.adb.oraclecloud.com +``` + + +Oracle Cloud Autonomous Database sử dụng cổng 1522 và yêu cầu wallet cho kết nối mTLS. Tải wallet từ OCI Console và cấu hình kết nối tương ứng. + + +### Oracle Database từ xa + +``` +Name: Production Oracle +Host: oracle.example.com +Port: 1521 +Username: app_user +Password: (mật khẩu của bạn) +Service Name: ORCL +``` + +## Tính năng + +### Hỗ trợ Schema + +Schema Oracle ánh xạ trực tiếp với người dùng Oracle. Mỗi user sở hữu một schema cùng tên. TablePro hiển thị: + +1. Tất cả schema có thể truy cập bởi người dùng của bạn +2. Bảng, view và các đối tượng khác trong mỗi schema +3. Schema context hiện tại + +### Chuyển đổi Schema + +Chuyển đổi giữa các schema Oracle bằng trình chuyển đổi database (**Cmd+K**): + +1. Nhấn **Cmd+K** để mở trình chuyển đổi +2. Chọn schema đích +3. Sidebar, truy vấn và toolbar sẽ cập nhật theo schema đã chọn + + +Các schema hệ thống như SYS, SYSTEM, OUTLN, DBSNMP, APPQOSSYS, WMSYS và XDB được lọc khỏi danh sách schema theo mặc định. + + +### Duyệt bảng và View + +Duyệt bảng, view, materialized view và synonym. Data grid hỗ trợ phân trang sử dụng cú pháp `OFFSET`/`FETCH` của Oracle cho các bảng lớn. + +### Thực thi truy vấn + +Thực thi truy vấn với hỗ trợ Oracle SQL và PL/SQL. SQL editor cung cấp syntax highlighting cho các hàm và từ khóa riêng của Oracle. + +```sql +-- Phân trang với OFFSET/FETCH (12c+) +SELECT * FROM employees +ORDER BY employee_id +OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY; + +-- Bảng DUAL cho biểu thức +SELECT SYSDATE, USER, SYS_CONTEXT('USERENV', 'DB_NAME') FROM DUAL; + +-- Khối PL/SQL ẩn danh +BEGIN + DBMS_OUTPUT.PUT_LINE('Schema hiện tại: ' || SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA')); +END; +/ + +-- Truy vấn phân cấp với CONNECT BY +SELECT employee_id, manager_id, LEVEL +FROM employees +START WITH manager_id IS NULL +CONNECT BY PRIOR employee_id = manager_id; + +-- Truy vấn flashback +SELECT * FROM orders AS OF TIMESTAMP (SYSTIMESTAMP - INTERVAL '1' HOUR); +``` + +### Tạo DDL + +Xem DDL cho bất kỳ bảng nào sử dụng package `DBMS_METADATA` của Oracle. Tạo câu lệnh CREATE TABLE hoàn chỉnh bao gồm mệnh đề storage, gán tablespace, ràng buộc và định nghĩa index. + +### 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ừ file CSV, JSON, SQL và XLSX vào các bảng Oracle. + +### Duyệt Index + +TablePro hiển thị tất cả loại index trong tab Structure: + +- **B-tree indexes** (mặc định) +- **Bitmap indexes** +- **Function-based indexes** +- **Composite indexes** +- **Unique indexes** + +### Điều hướng Foreign Key + +Oracle thực thi ràng buộc foreign key với tính toàn vẹn tham chiếu đầy đủ. TablePro hiển thị các mối quan hệ này và cho phép điều hướng giữa các bảng liên quan bằng cách nhấp vào giá trị foreign key. + +## SSL/TLS + +Kết nối Oracle hỗ trợ mã hóa SSL/TLS qua Oracle Net Services: + +| Chế độ SSL | Mô tả | +|----------|-------------| +| **Disabled** | Không mã hóa SSL | +| **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 | + + +Đối với Oracle Cloud Autonomous Database, tải connection wallet từ OCI Console. Wallet chứa các chứng chỉ và mô tả kết nối cần thiết cho xác thực mTLS. + + +## Hỗ trợ SSH Tunnel + +Bạn có thể kết nối đến Oracle qua SSH tunnel nếu máy chủ database nằm trong mạng private. Cấu hình SSH tunnel trong tab SSH của form kết nối, sau đó TablePro định tuyến kết nối Oracle qua tunnel. + +## Khắc phục sự cố + +### Không tìm thấy Driver OCI + +**Triệu chứng**: "OCI library not found" hoặc "Cannot load OCI shared library" + +**Giải pháp**: + +1. Xác minh Oracle Instant Client đã cài đặt và các thư viện chia sẻ có thể truy cập. +2. Kiểm tra `ORACLE_HOME` hoặc `DYLD_LIBRARY_PATH` trỏ đến thư mục Instant Client. +3. Trên Mac Apple Silicon, đảm bảo bạn tải phiên bản arm64 của Instant Client. + +### Connection Refused + +**Triệu chứng**: "ORA-12541: TNS:no listener" hoặc timeout kết nối + +**Nguyên nhân phổ biến**: + +1. **Listener không chạy**: Xác minh Oracle listener đang chạy với `lsnrctl status` trên server. +2. **Sai port**: Cổng listener mặc định là 1521. Kiểm tra `listener.ora` cho cổng tùy chỉnh. +3. **Quy tắc tường lửa**: Đảm bảo cổng 1521 được mở trong security group hoặc tường lửa mạng. +4. **Sai service name**: Xác minh service name với `lsnrctl services` trên server. + +### Authentication Failed + +**Triệu chứng**: "ORA-01017: invalid username/password; logon denied" + +**Giải pháp**: + +1. Mật khẩu Oracle phân biệt hoa thường (12c+ với `SEC_CASE_SENSITIVE_LOGON=TRUE`). +2. Kiểm tra tài khoản có bị khóa không: `SELECT account_status FROM dba_users WHERE username = 'YOUR_USER'`. +3. Xác minh user tồn tại và có quyền CREATE SESSION. + +### ORA-12514: TNS:listener does not currently know of service + +**Giải pháp**: + +1. Xác nhận service name chính xác. Liệt kê các service đã đăng ký với `lsnrctl services`. +2. Instance database có thể chưa đăng ký với listener. Đợi tự động đăng ký hoặc chạy `ALTER SYSTEM REGISTER`. +3. Kiểm tra xem bạn có đang dùng SID thay vì service name không. TablePro sử dụng service name. + +## Bước tiếp theo + + + + Kết nối đến Oracle database trong mạng private + + + Viết và thực thi truy vấn Oracle và khối PL/SQL + + + Import và export dữ liệu Oracle + + + Xem indexes, foreign keys và định nghĩa cột + + diff --git a/docs/vi/databases/overview.mdx b/docs/vi/databases/overview.mdx index fe13fae7..ec3c5647 100644 --- a/docs/vi/databases/overview.mdx +++ b/docs/vi/databases/overview.mdx @@ -99,7 +99,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` và `cockroachdb` 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:** @@ -560,6 +560,9 @@ TablePro tự động đặt cổng mặc định khi bạn chọn loại cơ s 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