Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Oracle 10G password verifier authentication. Accounts whose `password_versions` includes a 10G hash now connect successfully, matching DBeaver/JDBC/sqlplus behavior. The 10G hash is documented as legacy; rotating to a modern verifier is still recommended (#483)
- Oracle Test Connection now opens a focused diagnostic sheet for auth failures with copy-able diagnostic info, suggested actions, and a link to file an issue
- Oracle connection negotiation now matches python-oracledb's 23ai compile-capability advertisement, including TTC4 explicit boundary, TTC5 token/pipelining/sessionless flags, OCI3 sync, dequeue selectors, and sparse vector features

### Changed

- Internal: introduce `TabSession` as the foundation type for the editor tab/window subsystem rewrite. Currently a parallel structure mirroring `QueryTab`; subsequent PRs migrate state ownership and lifecycle hooks per `docs/architecture/tab-subsystem-rewrite.md`. No user-visible behavior change in this PR.
Expand All @@ -19,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Tab switching: rapid Cmd+Number presses no longer leave a tail of tab transitions playing after the user releases the keys. The tab-selection setter (`NSWindowTabGroup.selectedWindow`) is now wrapped in `NSAnimationContext.runAnimationGroup` with `duration = 0`, so AppKit applies each switch synchronously without queuing a CAAnimation. Lazy-load also moved out of `windowDidBecomeKey` into `.task(id:)` view-appearance lifecycle per Apple's documentation. Note: extreme Cmd+Number bursts (e.g. holding the key for key-repeat) still incur per-switch AppKit window-focus overhead; this is platform-inherent to native NSWindow tabs and documented in `docs/architecture/tab-subsystem-rewrite.md` D2
- Oracle TIMESTAMP, TIMESTAMP WITH TIME ZONE, TIMESTAMP WITH LOCAL TIME ZONE, INTERVAL DAY TO SECOND, INTERVAL YEAR TO MONTH, DATE, RAW, and BLOB columns now render through typed decoders instead of garbled text. Tables containing INTERVAL YEAR TO MONTH or BFILE columns no longer crash the app on row fetch. Unknown column types display `<unsupported: type>` instead of crashing (#965)
- Oracle connections to 23ai cloud and containerized deployments no longer fail with `uncleanShutdown` mid-handshake. OOB urgent-byte send now requires the server to advertise `TNS_ACCEPT_FLAG_CHECK_OOB`, matching python-oracledb behavior (#483)

## [0.37.0] - 2026-05-01

Expand Down
50 changes: 45 additions & 5 deletions Plugins/OracleDriverPlugin/OracleConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,36 @@ private let osLogger = Logger(subsystem: "com.TablePro", category: "OracleConnec
// MARK: - Error Types

struct OracleError: Error {
enum Category: Sendable, Equatable {
case generic
case notConnected
case connectionFailed
case queryFailed
case authVerifierUnsupported(flag: String)
case authVersionNotSupported
case authConnectionDropped
}

let message: String
let category: Category

init(message: String, category: Category = .generic) {
self.message = message
self.category = category
}

static let notConnected = OracleError(message: String(localized: "Not connected to database"))
static let connectionFailed = OracleError(message: String(localized: "Failed to establish connection"))
static let queryFailed = OracleError(message: String(localized: "Query execution failed"))
static let notConnected = OracleError(
message: String(localized: "Not connected to database"),
category: .notConnected
)
static let connectionFailed = OracleError(
message: String(localized: "Failed to establish connection"),
category: .connectionFailed
)
static let queryFailed = OracleError(
message: String(localized: "Query execution failed"),
category: .queryFailed
)
}

extension OracleError: PluginDriverError {
Expand Down Expand Up @@ -147,11 +172,26 @@ final class OracleConnectionWrapper: @unchecked Sendable {
} catch let sqlError as OracleSQLError {
let detail = sqlError.serverInfo?.message ?? sqlError.description
osLogger.error("Oracle connection failed: \(detail)")
throw OracleError(message: "Failed to connect to \(host):\(port)/\(service): \(detail)")
throw OracleError(message: detail, category: classifyConnectError(sqlError))
} catch {
let detail = String(describing: error)
osLogger.error("Oracle connection failed: \(detail)")
throw OracleError(message: "Failed to connect to \(host):\(port)/\(service): \(detail)")
throw OracleError(message: detail, category: .connectionFailed)
}
}

private func classifyConnectError(_ error: OracleSQLError) -> OracleError.Category {
let codeDescription = error.code.description
if codeDescription.hasPrefix("unsupportedVerifierType") {
return .authVerifierUnsupported(flag: codeDescription)
}
switch codeDescription {
case "uncleanShutdown":
return .authConnectionDropped
case "serverVersionNotSupported":
return .authVersionNotSupported
default:
return .connectionFailed
}
}

Expand Down
49 changes: 47 additions & 2 deletions Plugins/OracleDriverPlugin/OraclePlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import Foundation
import os
import TableProPluginKit

final class OraclePlugin: NSObject, TableProPlugin, DriverPlugin {
final class OraclePlugin: NSObject, TableProPlugin, DriverPlugin, PluginDiagnosticProvider {
static let pluginName = "Oracle Driver"
static let pluginVersion = "1.0.0"
static let pluginDescription = "Oracle Database support via OracleNIO"
Expand All @@ -16,7 +16,7 @@ final class OraclePlugin: NSObject, TableProPlugin, DriverPlugin {
static let databaseTypeId = "Oracle"
static let databaseDisplayName = "Oracle"
static let iconName = "oracle-icon"
static let defaultPort = 1521
static let defaultPort = 1_521
static let additionalConnectionFields: [ConnectionField] = [
ConnectionField(id: "oracleServiceName", label: "Service Name", placeholder: "ORCL")
]
Expand Down Expand Up @@ -1104,6 +1104,51 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
return raw.replacingOccurrences(of: "'", with: "''")
}

func diagnose(error: Error) -> PluginDiagnostic? {
guard let oracleError = error as? OracleError else { return nil }
let issuesURL = URL(string: "https://github.com/TableProApp/TablePro/issues")
switch oracleError.category {
case .authVerifierUnsupported(let flag):
return PluginDiagnostic(
title: String(localized: "Unsupported Password Verifier"),
message: oracleError.message,
suggestedActions: [
String(localized: "Verify the user account exists and the password is correct."),
String(localized: "Ask your DBA to confirm the user has an 11G or 12C password verifier (SELECT password_versions FROM dba_users WHERE username = '<USER>')."),
String(localized: "If the verifier is brand-new (e.g. 23ai), file an issue with the verifier flag below.")
],
diagnosticInfo: [
DiagnosticEntry(label: "Verifier flag", value: flag)
],
supportURL: issuesURL
)
case .authConnectionDropped:
return PluginDiagnostic(
title: String(localized: "Connection Dropped During Handshake"),
message: oracleError.message,
suggestedActions: [
String(localized: "If the same connection works in DBeaver or sqlplus, this is likely an OOB compatibility issue with cloud-hosted Oracle."),
String(localized: "TablePro 1.2.0 already gates OOB on the server flag, so most cases are resolved. If you still hit this, file an issue."),
String(localized: "Try disabling SSH tunnel or load balancer firewall rules between client and server.")
],
supportURL: URL(string: "https://github.com/TableProApp/TablePro/issues/483")
)
case .authVersionNotSupported:
return PluginDiagnostic(
title: String(localized: "Server Version Not Supported"),
message: oracleError.message,
suggestedActions: [
String(localized: "TablePro requires Oracle 12c or later via the OracleNIO Swift driver."),
String(localized: "Check the user account's password_versions; only 10G, 11G, and 12C are supported."),
String(localized: "Rotate the password under modern auth if password_versions contains an unrecognized verifier.")
],
supportURL: issuesURL
)
case .generic, .notConnected, .connectionFailed, .queryFailed:
return nil
}
}

private static let fromTableRegex = try? NSRegularExpression(
pattern: #"FROM\s+(?:"([^"]+)"|(\w+))"#,
options: .caseInsensitive
Expand Down
42 changes: 42 additions & 0 deletions Plugins/TableProPluginKit/PluginDiagnostic.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
//
// PluginDiagnostic.swift
// TableProPluginKit
//

import Foundation

public struct PluginDiagnostic: Sendable, Equatable {
public let title: String
public let message: String
public let suggestedActions: [String]
public let diagnosticInfo: [DiagnosticEntry]
public let supportURL: URL?

public init(
title: String,
message: String,
suggestedActions: [String] = [],
diagnosticInfo: [DiagnosticEntry] = [],
supportURL: URL? = nil
) {
self.title = title
self.message = message
self.suggestedActions = suggestedActions
self.diagnosticInfo = diagnosticInfo
self.supportURL = supportURL
}
}

public struct DiagnosticEntry: Sendable, Equatable {
public let label: String
public let value: String

public init(label: String, value: String) {
self.label = label
self.value = value
}
}

public protocol PluginDiagnosticProvider: AnyObject, Sendable {
func diagnose(error: Error) -> PluginDiagnostic?
}
2 changes: 1 addition & 1 deletion TablePro.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -4042,7 +4042,7 @@
repositoryURL = "https://github.com/TableProApp/oracle-nio";
requirement = {
kind = revision;
revision = f343a0db14aba73e50a6f93bd981d3b07a61c6d4;
revision = 7c01c8ff2e13794650719ebfa0294aa4281bbdd8;
};
};
/* End XCRemoteSwiftPackageReference section */
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions TablePro/Core/Plugins/PluginManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,12 @@ final class PluginManager {
return entry
}

func diagnose(error: Error, for type: DatabaseType) -> PluginDiagnostic? {
guard let driver = driverPlugins[type.pluginTypeId] else { return nil }
guard let provider = driver as? PluginDiagnosticProvider else { return nil }
return provider.diagnose(error: error)
}

func replaceExistingPlugin(bundleId: String) {
guard let existingIndex = plugins.firstIndex(where: { $0.id == bundleId }) else { return }
unregisterCapabilities(pluginId: bundleId)
Expand Down
18 changes: 18 additions & 0 deletions TablePro/Views/Connection/ConnectionFormView+Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,17 @@ extension ConnectionFormView {
}

func loadConnectionData() {
let connectionFormLog = Logger(subsystem: "com.TablePro", category: "ConnectionForm")
connectionFormLog.debug(
"[trace] loadConnectionData connectionId=\(self.connectionId?.uuidString ?? "nil", privacy: .public) isNew=\(self.connectionId == nil)"
)
sshState.profiles = SSHProfileStorage.shared.loadProfiles()
if let id = connectionId,
let existing = storage.loadConnections().first(where: { $0.id == id })
{
connectionFormLog.debug(
"[trace] loadConnectionData found existing id=\(existing.id.uuidString, privacy: .public) name='\(existing.name, privacy: .public)' promptForPassword=\(existing.promptForPassword)"
)
originalConnection = existing
name = existing.name
host = existing.host
Expand Down Expand Up @@ -162,6 +169,13 @@ extension ConnectionFormView {
// Load connection password from Keychain
if let savedPassword = storage.loadPassword(for: existing.id) {
password = savedPassword
connectionFormLog.debug(
"[trace] loadConnectionData password populated length=\(savedPassword.count)"
)
} else {
connectionFormLog.debug(
"[trace] loadConnectionData password NOT populated (loadPassword returned nil)"
)
}
}
Task { @MainActor in
Expand Down Expand Up @@ -486,6 +500,10 @@ extension ConnectionFormView {
testSucceeded = false
if case PluginError.pluginNotInstalled = error {
pluginInstallConnection = testConn
} else if let item = PluginDiagnosticItem.classify(
error: error, connection: testConn, username: finalUsername
) {
pluginDiagnostic = item
} else {
AlertHelper.showErrorSheet(
title: String(localized: "Connection Test Failed"),
Expand Down
7 changes: 7 additions & 0 deletions TablePro/Views/Connection/ConnectionFormView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ struct ConnectionFormView: View {
@State var isInstallingPlugin: Bool = false
@State var pluginInstallError: String?

@State var pluginDiagnostic: PluginDiagnosticItem?

// Tab selection
@State var selectedTab: FormTab = .general

Expand Down Expand Up @@ -194,6 +196,11 @@ struct ConnectionFormView: View {
.pluginInstallPrompt(connection: $pluginInstallConnection) { connection in
connectAfterInstall(connection)
}
.sheet(item: $pluginDiagnostic) { item in
PluginDiagnosticSheet(item: item) {
pluginDiagnostic = nil
}
}
.onChange(of: pgpassTrigger) { _, _ in updatePgpassStatus() }
.onChange(of: usePgpass) { _, newValue in if newValue { promptForPassword = false } }
}
Expand Down
Loading
Loading