diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c4c48b63..e2d833741 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,16 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Fixed - -- Query result columns now follow the order in the SELECT. Adding or removing a column no longer leaves new columns stuck at the end of the grid. (#1565) -- JSON file import works again. It failed to load in 0.48.0. -- SQL export quotes empty or malformed values in numeric columns instead of writing them unquoted, which could produce invalid INSERT statements. - ### Added - Each filter row has a checkbox to turn it on or off and an Apply button to filter by just that row. The main Apply runs every active filter, and disabled filters stay in the panel for later. (#1561) - Importing connections from other apps now detects duplicates by host, port, database, and username, and lets you replace, add a copy, or skip each one before import. +- Oracle connections negotiate Native Network Encryption when the server asks for it, so servers with `SQLNET.ENCRYPTION_SERVER` or `SQLNET.CRYPTO_CHECKSUM_SERVER` set to REQUIRED now connect (AES with a SHA crypto-checksum), matching what SQL Developer and DBeaver do. (#483) +- Oracle connections follow listener redirects, so RAC SCAN listeners, shared server, and load-balanced setups now connect instead of failing during the handshake. (#483) ### Changed @@ -30,12 +26,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Query result columns now follow the order in the SELECT. Adding or removing a column no longer leaves new columns stuck at the end of the grid. (#1565) +- JSON file import works again. It failed to load in 0.48.0. +- SQL export quotes empty or malformed values in numeric columns instead of writing them unquoted, which could produce invalid INSERT statements. - SQL Server: connections work when the login can only reach its own database, such as an Azure SQL contained user. The database is now sent during login. Previously it was switched afterward, which the server rejected with a "Login failed" error. - Custom Copy and Cut shortcuts now take effect in the SQL editor. - The Delete shortcut in the data grid now follows a custom binding. - Find Next (Cmd+G) and Find Previous (Cmd+Shift+G) now work in the editor. - Pagination buttons no longer fire their page shortcut twice. - AWS IAM connections no longer ask for a password on connect or reconnect. IAM supplies the credentials, so the prompt was never needed. The same now holds for any auth mode that replaces the password, such as a Postgres password file. +- Oracle connection failures show the listener's actual reason (such as an unknown service name) instead of a generic "server closed the connection" message. (#483) ## [0.48.0] - 2026-06-02 diff --git a/Plugins/OracleDriverPlugin/OracleConnection.swift b/Plugins/OracleDriverPlugin/OracleConnection.swift index 943810cfa..d843dd342 100644 --- a/Plugins/OracleDriverPlugin/OracleConnection.swift +++ b/Plugins/OracleDriverPlugin/OracleConnection.swift @@ -189,7 +189,7 @@ final class OracleConnectionWrapper: @unchecked Sendable { let target = useSID ? "\(self.host):\(self.port):\(identifier)" : "\(self.host):\(self.port)/\(identifier)" osLogger.debug("Connected to Oracle \(target)") } catch let sqlError as OracleSQLError { - let detail = sqlError.serverInfo?.message ?? sqlError.description + let detail = Self.connectFailureDetail(sqlError) osLogger.error("Oracle connection failed: \(detail)") if let sslError = Self.classifySSLError(detail) { throw sslError @@ -246,6 +246,13 @@ final class OracleConnectionWrapper: @unchecked Sendable { } } + private static func connectFailureDetail(_ error: OracleSQLError) -> String { + if let refused = error.underlying as? OracleListenerRefusedError { + return OracleListenerRefusal.detail(code: refused.code) + } + return error.serverInfo?.message ?? error.description + } + private static func connectErrorMessage( for category: OracleError.Category, serverDetail: String diff --git a/Plugins/OracleDriverPlugin/OracleListenerRefusal.swift b/Plugins/OracleDriverPlugin/OracleListenerRefusal.swift new file mode 100644 index 000000000..9cb96edf6 --- /dev/null +++ b/Plugins/OracleDriverPlugin/OracleListenerRefusal.swift @@ -0,0 +1,28 @@ +import Foundation + +enum OracleListenerRefusal { + static func detail(code: Int?) -> String { + guard let code else { + return String(localized: "The Oracle listener refused the connection.") + } + if let reason = reason(forCode: code) { + return String(format: String(localized: "%1$@ (ORA-%2$ld)."), reason, code) + } + return String(format: String(localized: "The Oracle listener refused the connection (ORA-%ld)."), code) + } + + static func reason(forCode code: Int) -> String? { + switch code { + case 12_514: + return String(localized: "The listener does not know the requested service name") + case 12_505: + return String(localized: "The listener does not know the requested SID") + case 12_516, 12_519, 12_520: + return String(localized: "The listener has no handler available for the requested service") + case 12_528: + return String(localized: "The listener is blocking new connections to the requested service") + default: + return nil + } + } +} diff --git a/Plugins/OracleDriverPlugin/OraclePlugin.swift b/Plugins/OracleDriverPlugin/OraclePlugin.swift index 0fffc29c8..c43e1d371 100644 --- a/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -143,10 +143,9 @@ final class OraclePlugin: NSObject, TableProPlugin, DriverPlugin, PluginDiagnost title: String(localized: "Connection Dropped During Handshake"), message: oracleError.message, suggestedActions: [ - String(localized: "The server may require Native Network Encryption, which the pure-Swift driver cannot negotiate."), - String(localized: "Configure the listener for TLS, or set SQLNET.ENCRYPTION_SERVER to ACCEPTED instead of REQUIRED."), - String(localized: "If the same connection works in DBeaver or SQL Developer, they use Oracle's OCI client, which supports Native Network Encryption."), - String(localized: "Check for a firewall or load balancer between the client and server that closes connections mid-handshake.") + String(localized: "Check for a firewall, VPN, or load balancer between you and the server that closes connections mid-handshake."), + String(localized: "If the listener endpoint is TLS-only (TCPS), set the SSL mode in the connection's SSL settings."), + String(localized: "Confirm the host and port reach the database listener directly, not a proxy that resets unknown traffic.") ], supportURL: URL(string: "https://github.com/TableProApp/TablePro/issues/483") ) diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 927b392db..3b3f398e9 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -4451,7 +4451,7 @@ repositoryURL = "https://github.com/TableProApp/oracle-nio"; requirement = { kind = revision; - revision = 254b72adfb6b527ac45895b42a38e60ba6c77a1f; + revision = 04a4e5967bf4d96cadf66081ea5a22133fe403ea; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4387a72dc..82e54457b 100644 --- a/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { "originHash" : "475d5c8ee85aa1d1e11facad23a0ba0c8149e7ea855d30de709ab6e3dd78d3be", "pins" : [ + { + "identity" : "bigint", + "kind" : "remoteSourceControl", + "location" : "https://github.com/attaswift/BigInt.git", + "state" : { + "revision" : "e07e00fa1fd435143a2dcf8b7eec9a7710b2fdfe", + "version" : "5.7.0" + } + }, { "identity" : "codeeditsymbols", "kind" : "remoteSourceControl", @@ -15,7 +24,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/TableProApp/oracle-nio", "state" : { - "revision" : "254b72adfb6b527ac45895b42a38e60ba6c77a1f" + "revision" : "04a4e5967bf4d96cadf66081ea5a22133fe403ea" } }, { diff --git a/TableProTests/PluginTestSources/OracleListenerRefusal.swift b/TableProTests/PluginTestSources/OracleListenerRefusal.swift new file mode 120000 index 000000000..ffccd135b --- /dev/null +++ b/TableProTests/PluginTestSources/OracleListenerRefusal.swift @@ -0,0 +1 @@ +../../Plugins/OracleDriverPlugin/OracleListenerRefusal.swift \ No newline at end of file diff --git a/TableProTests/Plugins/OracleListenerRefusalTests.swift b/TableProTests/Plugins/OracleListenerRefusalTests.swift new file mode 100644 index 000000000..05b446607 --- /dev/null +++ b/TableProTests/Plugins/OracleListenerRefusalTests.swift @@ -0,0 +1,38 @@ +import Foundation +import Testing + +@testable import TablePro + +@Suite("Oracle listener refusal detail") +struct OracleListenerRefusalTests { + @Test("Known listener codes map to a human reason with the ORA code") + func knownCodes() { + #expect(OracleListenerRefusal.detail(code: 12_514) + == "The listener does not know the requested service name (ORA-12514).") + #expect(OracleListenerRefusal.detail(code: 12_505) + == "The listener does not know the requested SID (ORA-12505).") + #expect(OracleListenerRefusal.detail(code: 12_528) + == "The listener is blocking new connections to the requested service (ORA-12528).") + } + + @Test("Handler-unavailable codes share one reason") + func handlerUnavailableCodes() { + for code in [12_516, 12_519, 12_520] { + #expect(OracleListenerRefusal.reason(forCode: code) + == "The listener has no handler available for the requested service") + } + } + + @Test("An unknown code falls back to the generic message with the code") + func unknownCode() { + #expect(OracleListenerRefusal.reason(forCode: 9_999) == nil) + #expect(OracleListenerRefusal.detail(code: 9_999) + == "The Oracle listener refused the connection (ORA-9999).") + } + + @Test("A missing code falls back to the generic message") + func missingCode() { + #expect(OracleListenerRefusal.detail(code: nil) + == "The Oracle listener refused the connection.") + } +} diff --git a/docs/databases/oracle.mdx b/docs/databases/oracle.mdx index 5222687b4..36eeab2b7 100644 --- a/docs/databases/oracle.mdx +++ b/docs/databases/oracle.mdx @@ -8,9 +8,11 @@ description: Connect to Oracle Database with TablePro TablePro supports Oracle Database 11.1 and later. It speaks the Oracle TNS wire protocol directly in Swift, so no Oracle Instant Client or other external library is required. This covers Oracle Database instances running on-premises, in Docker, or Oracle Cloud. -Oracle 10g and earlier are not supported: they use the older O3LOGON handshake that the pure-Swift driver does not implement. Servers that require Native Network Encryption also need Oracle's OCI client (use TLS instead). +Oracle 10g and earlier are not supported: they use the older O3LOGON handshake that the pure-Swift driver does not implement. +Servers behind a RAC SCAN listener, shared server, or a load balancer are supported: TablePro follows the listener's redirect to the instance that serves the session. Servers that require Native Network Encryption (`SQLNET.ENCRYPTION_SERVER` or `SQLNET.CRYPTO_CHECKSUM_SERVER` set to REQUIRED) are also supported; TablePro negotiates AES encryption with a SHA crypto-checksum, the same as SQL Developer and DBeaver. + ## Install Plugin The Oracle driver is available as a downloadable plugin. When you select Oracle in the connection form, TablePro will prompt you to install it automatically. You can also install it manually: