From 5381593bbbaf8cbe2d980f2bf912d8866a6ed24c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 3 May 2026 00:50:08 +0700 Subject: [PATCH] fix(oracle): decode TIMESTAMP, INTERVAL, and BFILE through typed paths (#965) --- CHANGELOG.md | 4 + .../OracleCellFormatting.swift | 108 ++++++++++++++ .../OracleDriverPlugin/OracleConnection.swift | 119 +++++++++++++-- TablePro.xcodeproj/project.pbxproj | 69 ++++----- .../xcshareddata/swiftpm/Package.resolved | 31 ++-- .../OracleCellFormatting.swift | 1 + .../Plugins/OracleCellFormattingTests.swift | 139 ++++++++++++++++++ docs/databases/oracle.mdx | 22 ++- 8 files changed, 433 insertions(+), 60 deletions(-) create mode 100644 Plugins/OracleDriverPlugin/OracleCellFormatting.swift create mode 120000 TableProTests/PluginTestSources/OracleCellFormatting.swift create mode 100644 TableProTests/Plugins/OracleCellFormattingTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ac0290a9..8ce954a75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- 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 `` instead of crashing (#965) + ## [0.37.0] - 2026-05-01 ### Added diff --git a/Plugins/OracleDriverPlugin/OracleCellFormatting.swift b/Plugins/OracleDriverPlugin/OracleCellFormatting.swift new file mode 100644 index 000000000..108ad74e6 --- /dev/null +++ b/Plugins/OracleDriverPlugin/OracleCellFormatting.swift @@ -0,0 +1,108 @@ +// +// OracleCellFormatting.swift +// OracleDriverPlugin +// + +import Foundation + +enum OracleCellFormatting { + static let maxHexBytes = 4_096 + + enum TimestampStyle { + case utc + case local + case zoned + } + + static let dateOnlyFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + + private static let utcFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter + }() + + private static let localFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + formatter.timeZone = .current + return formatter + }() + + private static let zonedFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds, .withTimeZone] + return formatter + }() + + static func formatDate(_ date: Date) -> String { + dateOnlyFormatter.string(from: date) + } + + static func formatTimestamp(_ date: Date, style: TimestampStyle) -> String { + switch style { + case .utc: + return utcFormatter.string(from: date) + case .local: + return localFormatter.string(from: date) + case .zoned: + return zonedFormatter.string(from: date) + } + } + + static func formatIntervalDS( + days: Int, + hours: Int, + minutes: Int, + seconds: Int, + nanoseconds: Int + ) -> String { + let isNegative = days < 0 || hours < 0 || minutes < 0 + || seconds < 0 || nanoseconds < 0 + let sign = isNegative ? "-" : "" + let base = String( + format: "%@%d %02d:%02d:%02d", + sign, + abs(days), + abs(hours), + abs(minutes), + abs(seconds) + ) + let absNanos = abs(nanoseconds) + if absNanos == 0 { + return base + } + var fractional = String(format: "%09d", absNanos) + while fractional.last == "0" { + fractional.removeLast() + } + return "\(base).\(fractional)" + } + + static func formatIntervalYM(years: Int, months: Int) -> String { + let isNegative = years < 0 || months < 0 + let sign = isNegative ? "-" : "" + return String(format: "%@%d-%02d", sign, abs(years), abs(months)) + } + + static func hexEncode(_ bytes: [UInt8]) -> String { + let totalBytes = bytes.count + let limit = min(totalBytes, maxHexBytes) + let hex = bytes.prefix(limit).map { String(format: "%02x", $0) }.joined() + if totalBytes > limit { + return "\(hex)… (\(totalBytes) bytes)" + } + return hex + } + + static func unsupportedPlaceholder(typeName: String) -> String { + "" + } +} diff --git a/Plugins/OracleDriverPlugin/OracleConnection.swift b/Plugins/OracleDriverPlugin/OracleConnection.swift index e788ab390..4471c7396 100644 --- a/Plugins/OracleDriverPlugin/OracleConnection.swift +++ b/Plugins/OracleDriverPlugin/OracleConnection.swift @@ -65,6 +65,18 @@ private actor QueryGate { } } +// MARK: - Unsupported Type Warner + +private actor UnsupportedTypeWarner { + private var seen: Set = [] + + func warnIfNew(_ typeName: String) -> Bool { + guard !seen.contains(typeName) else { return false } + seen.insert(typeName) + return true + } +} + // MARK: - Connection Class final class OracleConnectionWrapper: @unchecked Sendable { @@ -318,18 +330,105 @@ final class OracleConnectionWrapper: @unchecked Sendable { } } - // MARK: - Private Helpers + // MARK: - Cell Decoding + + private let unsupportedWarner = UnsupportedTypeWarner() - /// Decode an OracleCell to String, trying multiple type strategies. - /// OracleNIO may fail to decode NUMBER as String directly. private func decodeCell(_ cell: OracleCell) -> String? { - if let value = try? cell.decode(String.self) { return value } - if let value = try? cell.decode(Int.self) { return String(value) } - if let value = try? cell.decode(Double.self) { return String(value) } - if let value = try? cell.decode(Bool.self) { return String(value) } - // Last resort: read raw bytes as UTF-8 - if var buf = cell.bytes { - return buf.readString(length: buf.readableBytes) + guard cell.bytes != nil else { return nil } + + do { + switch cell.dataType { + case .varchar, .nVarchar, .char, .nChar, .long, .longNVarchar, + .clob, .nCLOB, .json, .rowID: + return try cell.decode(String.self) + + case .number, .binaryInteger: + return Self.decodeNumber(cell) + + case .binaryFloat: + return String(try cell.decode(Float.self)) + + case .binaryDouble: + return String(try cell.decode(Double.self)) + + case .boolean: + return try cell.decode(Bool.self) ? "true" : "false" + + case .date: + return OracleCellFormatting.formatDate(try cell.decode(Date.self)) + + case .timestamp: + return OracleCellFormatting.formatTimestamp(try cell.decode(Date.self), style: .utc) + + case .timestampLTZ, .timestampTZ: + return OracleCellFormatting.formatTimestamp(try cell.decode(Date.self), style: .local) + + case .intervalDS: + let interval = try cell.decode(IntervalDS.self) + return OracleCellFormatting.formatIntervalDS( + days: interval.days, + hours: interval.hours, + minutes: interval.minutes, + seconds: interval.seconds, + nanoseconds: interval.fractionalSeconds + ) + + case .intervalYM: + let interval = try cell.decode(IntervalYM.self) + return OracleCellFormatting.formatIntervalYM( + years: interval.years, + months: interval.months + ) + + case .raw, .longRAW, .blob: + return Self.hexEncode(cell.bytes) + + case .bFile: + return "" + + case .cursor: + return "" + + case .vector: + return "" + + default: + return unsupportedPlaceholder(for: cell.dataType) + } + } catch { + osLogger.error("Oracle decode failed for column '\(cell.columnName)' type \(self.oracleTypeName(cell.dataType)): \(String(describing: error))") + return "" + } + } + + private func unsupportedPlaceholder(for type: OracleDataType) -> String { + let name = oracleTypeName(type) + let warner = unsupportedWarner + Task.detached { + if await warner.warnIfNew(name) { + osLogger.warning("Oracle column type '\(name)' is not supported; rendering as placeholder") + } + } + return OracleCellFormatting.unsupportedPlaceholder(typeName: name) + } + + private static func hexEncode(_ buffer: ByteBuffer?) -> String? { + guard var copy = buffer else { return nil } + let total = copy.readableBytes + guard let bytes = copy.readBytes(length: total) else { return nil } + return OracleCellFormatting.hexEncode(bytes) + } + + private static func decodeNumber(_ cell: OracleCell) -> String? { + if let value = try? cell.decode(Int.self) { + return String(value) + } + if let value = try? cell.decode(OracleNumber.self) { + return value.description + } + if let value = try? cell.decode(Double.self) { + return String(value) } return nil } diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index fae3e53a5..8ce17ff20 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -8,7 +8,7 @@ /* Begin PBXBuildFile section */ 5A32BBFB2F9D5EAB00BAEB5F /* X509 in Frameworks */ = {isa = PBXBuildFile; productRef = 5A32BBFA2F9D5EAB00BAEB5F /* X509 */; }; - 5A32BC0B2F9D659100BAEB5F /* tablepro-mcp in CopyFiles */ = {isa = PBXBuildFile; fileRef = 5A32BC002F9D5F1300BAEB5F /* tablepro-mcp */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + 5A32BC0B2F9D659100BAEB5F /* tablepro-mcp in Copy Files */ = {isa = PBXBuildFile; fileRef = 5A32BC002F9D5F1300BAEB5F /* tablepro-mcp */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 5A3A69B82F976F38000AC5B2 /* GhosttyTerminal in Frameworks */ = {isa = PBXBuildFile; productRef = 5A3A69B72F976F38000AC5B2 /* GhosttyTerminal */; }; 5A3A69BA2F976F38000AC5B2 /* GhosttyTheme in Frameworks */ = {isa = PBXBuildFile; productRef = 5A3A69B92F976F38000AC5B2 /* GhosttyTheme */; }; 5A3BE6FC2F97DB0000611C1F /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; @@ -16,30 +16,30 @@ 5A860000A00000000 /* TableProPluginKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A861000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5A862000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; - 5A862000D00000000 /* SQLiteDriver.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A862000100000000 /* SQLiteDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 5A862000D00000000 /* SQLiteDriver.tableplugin in Copy Plug-Ins (12 items) */ = {isa = PBXBuildFile; fileRef = 5A862000100000000 /* SQLiteDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A863000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; - 5A863000D00000000 /* ClickHouseDriver.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A863000100000000 /* ClickHouseDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 5A863000D00000000 /* ClickHouseDriver.tableplugin in Copy Plug-Ins (12 items) */ = {isa = PBXBuildFile; fileRef = 5A863000100000000 /* ClickHouseDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A864000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5A865000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; - 5A865000D00000000 /* MySQLDriver.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A865000100000000 /* MySQLDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 5A865000D00000000 /* MySQLDriver.tableplugin in Copy Plug-Ins (12 items) */ = {isa = PBXBuildFile; fileRef = 5A865000100000000 /* MySQLDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A866000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5A867000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; - 5A867000D00000000 /* RedisDriver.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A867000100000000 /* RedisDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 5A867000D00000000 /* RedisDriver.tableplugin in Copy Plug-Ins (12 items) */ = {isa = PBXBuildFile; fileRef = 5A867000100000000 /* RedisDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A868000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; - 5A868000D00000000 /* PostgreSQLDriver.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A868000100000000 /* PostgreSQLDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 5A868000D00000000 /* PostgreSQLDriver.tableplugin in Copy Plug-Ins (12 items) */ = {isa = PBXBuildFile; fileRef = 5A868000100000000 /* PostgreSQLDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A869000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5A86A000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; - 5A86A000D00000000 /* CSVExport.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A86A000100000000 /* CSVExport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 5A86A000D00000000 /* CSVExport.tableplugin in Copy Plug-Ins (12 items) */ = {isa = PBXBuildFile; fileRef = 5A86A000100000000 /* CSVExport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A86B000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; - 5A86B000D00000000 /* JSONExport.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A86B000100000000 /* JSONExport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 5A86B000D00000000 /* JSONExport.tableplugin in Copy Plug-Ins (12 items) */ = {isa = PBXBuildFile; fileRef = 5A86B000100000000 /* JSONExport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A86C000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; - 5A86C000D00000000 /* SQLExport.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A86C000100000000 /* SQLExport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 5A86C000D00000000 /* SQLExport.tableplugin in Copy Plug-Ins (12 items) */ = {isa = PBXBuildFile; fileRef = 5A86C000100000000 /* SQLExport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A86D000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; - 5A86D000D00000000 /* XLSXExport.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A86D000100000000 /* XLSXExport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 5A86D000D00000000 /* XLSXExport.tableplugin in Copy Plug-Ins (12 items) */ = {isa = PBXBuildFile; fileRef = 5A86D000100000000 /* XLSXExport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A86E000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; - 5A86E000D00000000 /* MQLExport.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A86E000100000000 /* MQLExport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 5A86E000D00000000 /* MQLExport.tableplugin in Copy Plug-Ins (12 items) */ = {isa = PBXBuildFile; fileRef = 5A86E000100000000 /* MQLExport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A86F000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; - 5A86F000D00000000 /* SQLImport.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A86F000100000000 /* SQLImport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 5A86F000D00000000 /* SQLImport.tableplugin in Copy Plug-Ins (12 items) */ = {isa = PBXBuildFile; fileRef = 5A86F000100000000 /* SQLImport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A87A000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5ABQR00100000000000000A1 /* BigQueryAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ABQR00200000000000000A1 /* BigQueryAuth.swift */; }; 5ABQR00100000000000000A2 /* BigQueryConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ABQR00200000000000000A2 /* BigQueryConnection.swift */; }; @@ -72,6 +72,7 @@ 5AEA8B472F6808CA0040461A /* EtcdHttpClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEA8B3C2F6808CA0040461A /* EtcdHttpClient.swift */; }; 5AEA8B492F6808E90040461A /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5AEE5B362F5C9B7B00FA84D7 /* OracleNIO in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F00000000000F /* OracleNIO */; }; + 5AFB96092FA66D3000961BB5 /* OracleDriver.tableplugin in Copy Plug-Ins (12 items) */ = {isa = PBXBuildFile; fileRef = 5A861000100000000 /* OracleDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -220,35 +221,37 @@ ); runOnlyForDeploymentPostprocessing = 1; }; - 5A32BC0A2F9D657A00BAEB5F /* CopyFiles */ = { + 5A32BC0A2F9D657A00BAEB5F /* Copy Files */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 6; files = ( - 5A32BC0B2F9D659100BAEB5F /* tablepro-mcp in CopyFiles */, + 5A32BC0B2F9D659100BAEB5F /* tablepro-mcp in Copy Files */, ); + name = "Copy Files"; runOnlyForDeploymentPostprocessing = 0; }; - 5A86FF0000000000 /* Copy Plug-Ins */ = { + 5A86FF0000000000 /* Copy Plug-Ins (12 items) */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 13; files = ( - 5A865000D00000000 /* MySQLDriver.tableplugin in Copy Plug-Ins */, - 5A868000D00000000 /* PostgreSQLDriver.tableplugin in Copy Plug-Ins */, - 5A862000D00000000 /* SQLiteDriver.tableplugin in Copy Plug-Ins */, - 5A863000D00000000 /* ClickHouseDriver.tableplugin in Copy Plug-Ins */, - 5A867000D00000000 /* RedisDriver.tableplugin in Copy Plug-Ins */, - 5A86A000D00000000 /* CSVExport.tableplugin in Copy Plug-Ins */, - 5A86B000D00000000 /* JSONExport.tableplugin in Copy Plug-Ins */, - 5A86C000D00000000 /* SQLExport.tableplugin in Copy Plug-Ins */, - 5A86D000D00000000 /* XLSXExport.tableplugin in Copy Plug-Ins */, - 5A86E000D00000000 /* MQLExport.tableplugin in Copy Plug-Ins */, - 5A86F000D00000000 /* SQLImport.tableplugin in Copy Plug-Ins */, - ); - name = "Copy Plug-Ins"; + 5AFB96092FA66D3000961BB5 /* OracleDriver.tableplugin in Copy Plug-Ins (12 items) */, + 5A865000D00000000 /* MySQLDriver.tableplugin in Copy Plug-Ins (12 items) */, + 5A868000D00000000 /* PostgreSQLDriver.tableplugin in Copy Plug-Ins (12 items) */, + 5A862000D00000000 /* SQLiteDriver.tableplugin in Copy Plug-Ins (12 items) */, + 5A863000D00000000 /* ClickHouseDriver.tableplugin in Copy Plug-Ins (12 items) */, + 5A867000D00000000 /* RedisDriver.tableplugin in Copy Plug-Ins (12 items) */, + 5A86A000D00000000 /* CSVExport.tableplugin in Copy Plug-Ins (12 items) */, + 5A86B000D00000000 /* JSONExport.tableplugin in Copy Plug-Ins (12 items) */, + 5A86C000D00000000 /* SQLExport.tableplugin in Copy Plug-Ins (12 items) */, + 5A86D000D00000000 /* XLSXExport.tableplugin in Copy Plug-Ins (12 items) */, + 5A86E000D00000000 /* MQLExport.tableplugin in Copy Plug-Ins (12 items) */, + 5A86F000D00000000 /* SQLImport.tableplugin in Copy Plug-Ins (12 items) */, + ); + name = "Copy Plug-Ins (12 items)"; runOnlyForDeploymentPostprocessing = 0; }; 5A86FF0100000000 /* Embed Frameworks */ = { @@ -995,8 +998,8 @@ 5A1091C42EF17EDC0055EA7C /* Frameworks */, 5A1091C52EF17EDC0055EA7C /* Resources */, 5A86FF0100000000 /* Embed Frameworks */, - 5A86FF0000000000 /* Copy Plug-Ins */, - 5A32BC0A2F9D657A00BAEB5F /* CopyFiles */, + 5A86FF0000000000 /* Copy Plug-Ins (12 items) */, + 5A32BC0A2F9D657A00BAEB5F /* Copy Files */, ); buildRules = ( ); @@ -4038,10 +4041,10 @@ }; 5ACE00012F4F00000000000E /* XCRemoteSwiftPackageReference "oracle-nio" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/lovetodream/oracle-nio"; + repositoryURL = "https://github.com/TableProApp/oracle-nio"; requirement = { - kind = exactVersion; - version = "1.0.0-rc.4"; + kind = revision; + revision = f343a0db14aba73e50a6f93bd981d3b07a61c6d4; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index c92a06d25..4407295fb 100644 --- a/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "881465be2c521ba0ee7ee6b6ca78e7e3c341b89cb2839142d708ea7eba6fa2ff", + "originHash" : "ccbbba919cff7f0502bbda9aa6e6649b4d27cfcc4abba225f2b659610d6c0108", "pins" : [ { "identity" : "codeeditsymbols", @@ -40,10 +40,9 @@ { "identity" : "oracle-nio", "kind" : "remoteSourceControl", - "location" : "https://github.com/lovetodream/oracle-nio", + "location" : "https://github.com/TableProApp/oracle-nio", "state" : { - "revision" : "182c0f032326b5d437f80eb991570381cb48eb02", - "version" : "1.0.0-rc.4" + "revision" : "f343a0db14aba73e50a6f93bd981d3b07a61c6d4" } }, { @@ -132,8 +131,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-distributed-tracing.git", "state" : { - "revision" : "e109d8b5308d0e05201d9a1dd1c475446a946a11", - "version" : "1.4.0" + "revision" : "dc4030184203ffafbb2ec614352487235d747fe0", + "version" : "1.4.1" } }, { @@ -141,8 +140,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "bbd81b6725ae874c69e9b8c8804d462356b55523", - "version" : "1.10.1" + "revision" : "5073617dac96330a486245e4c0179cb0a6fd2256", + "version" : "1.12.0" } }, { @@ -159,8 +158,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "e932d3c4d8f77433c8f7093b5ebcbf91463948a0", - "version" : "2.95.0" + "revision" : "f71c8d2a5e74a2c6d11a0fbe324774b5d6084237", + "version" : "2.99.0" } }, { @@ -168,8 +167,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-ssl.git", "state" : { - "revision" : "173cc69a058623525a58ae6710e2f5727c663793", - "version" : "2.36.0" + "revision" : "3f337058ccd7243c4cac7911477d8ad4c598d4da", + "version" : "2.37.0" } }, { @@ -177,8 +176,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-transport-services.git", "state" : { - "revision" : "60c3e187154421171721c1a38e800b390680fb5d", - "version" : "1.26.0" + "revision" : "67787bb645a5e67d2edcdfbe48a216cc549222d5", + "version" : "1.28.0" } }, { @@ -195,8 +194,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swift-server/swift-service-lifecycle.git", "state" : { - "revision" : "89888196dd79c61c50bca9a103d8114f32e1e598", - "version" : "2.10.1" + "revision" : "9829955b385e5bb88128b73f1b8389e9b9c3191a", + "version" : "2.11.0" } }, { diff --git a/TableProTests/PluginTestSources/OracleCellFormatting.swift b/TableProTests/PluginTestSources/OracleCellFormatting.swift new file mode 120000 index 000000000..1ac0424c6 --- /dev/null +++ b/TableProTests/PluginTestSources/OracleCellFormatting.swift @@ -0,0 +1 @@ +../../Plugins/OracleDriverPlugin/OracleCellFormatting.swift \ No newline at end of file diff --git a/TableProTests/Plugins/OracleCellFormattingTests.swift b/TableProTests/Plugins/OracleCellFormattingTests.swift new file mode 100644 index 000000000..00e64949e --- /dev/null +++ b/TableProTests/Plugins/OracleCellFormattingTests.swift @@ -0,0 +1,139 @@ +// +// OracleCellFormattingTests.swift +// TableProTests +// + +import Foundation +import Testing + +@Suite("Oracle cell formatting") +struct OracleCellFormattingTests { + private static let referenceDate: Date = { + var components = DateComponents() + components.year = 2_026 + components.month = 5 + components.day = 3 + components.hour = 12 + components.minute = 29 + components.second = 44 + components.nanosecond = 123_000_000 + components.timeZone = TimeZone(secondsFromGMT: 0) + return Calendar(identifier: .gregorian).date(from: components) ?? Date(timeIntervalSince1970: 0) + }() + + @Test("DATE renders as POSIX yyyy-MM-dd in UTC") + func dateRendersAsCalendarDay() { + let result = OracleCellFormatting.formatDate(Self.referenceDate) + #expect(result == "2026-05-03") + } + + @Test("TIMESTAMP UTC style renders ISO-8601 with Z and fractional seconds") + func timestampUTC() { + let result = OracleCellFormatting.formatTimestamp(Self.referenceDate, style: .utc) + #expect(result == "2026-05-03T12:29:44.123Z") + } + + @Test("TIMESTAMP zoned style renders explicit offset, never bare Z") + func timestampZoned() { + let result = OracleCellFormatting.formatTimestamp(Self.referenceDate, style: .zoned) + #expect(!result.hasSuffix("Z")) + #expect(result.contains("2026-05-03T")) + } + + @Test("TIMESTAMP local style renders an offset matching the host's current zone") + func timestampLocal() { + let result = OracleCellFormatting.formatTimestamp(Self.referenceDate, style: .local) + #expect(!result.hasSuffix("Z")) + let expectedOffsetSeconds = TimeZone.current.secondsFromGMT(for: Self.referenceDate) + let sign = expectedOffsetSeconds >= 0 ? "+" : "-" + let offsetMagnitude = abs(expectedOffsetSeconds) + let hours = offsetMagnitude / 3_600 + let minutes = (offsetMagnitude % 3_600) / 60 + let expectedOffset = String(format: "%@%02d%02d", sign, hours, minutes) + #expect(result.hasSuffix(expectedOffset), "expected offset \(expectedOffset) at end of \(result)") + } + + @Test("INTERVAL DAY TO SECOND with milliseconds trims to significant digits") + func intervalMilliseconds() { + let result = OracleCellFormatting.formatIntervalDS( + days: 2, hours: 3, minutes: 4, seconds: 5, nanoseconds: 678_000_000 + ) + #expect(result == "2 03:04:05.678") + } + + @Test("INTERVAL DAY TO SECOND preserves nanosecond precision when present") + func intervalNanoseconds() { + let result = OracleCellFormatting.formatIntervalDS( + days: 0, hours: 0, minutes: 0, seconds: 0, nanoseconds: 123_456_789 + ) + #expect(result == "0 00:00:00.123456789") + } + + @Test("Negative INTERVAL DAY TO SECOND prefixes a single minus sign") + func intervalNegative() { + let result = OracleCellFormatting.formatIntervalDS( + days: -1, hours: -2, minutes: -3, seconds: -4, nanoseconds: -50_000_000 + ) + #expect(result == "-1 02:03:04.05") + } + + @Test("Zero fractional component drops the decimal point entirely") + func intervalZeroFractional() { + let result = OracleCellFormatting.formatIntervalDS( + days: 5, hours: 3, minutes: 14, seconds: 0, nanoseconds: 0 + ) + #expect(result == "5 03:14:00") + } + + @Test("Zero INTERVAL DAY TO SECOND has no sign and no decimal") + func intervalZero() { + let result = OracleCellFormatting.formatIntervalDS( + days: 0, hours: 0, minutes: 0, seconds: 0, nanoseconds: 0 + ) + #expect(result == "0 00:00:00") + } + + @Test("Positive INTERVAL YEAR TO MONTH formats as Y-MM") + func intervalYMPositive() { + let result = OracleCellFormatting.formatIntervalYM(years: 5, months: 3) + #expect(result == "5-03") + } + + @Test("Negative INTERVAL YEAR TO MONTH prefixes a minus sign") + func intervalYMNegative() { + let result = OracleCellFormatting.formatIntervalYM(years: -2, months: -7) + #expect(result == "-2-07") + } + + @Test("Zero INTERVAL YEAR TO MONTH renders as 0-00") + func intervalYMZero() { + let result = OracleCellFormatting.formatIntervalYM(years: 0, months: 0) + #expect(result == "0-00") + } + + @Test("Hex encode produces lowercase concatenated bytes") + func hexEncodeBasic() { + let bytes: [UInt8] = [0x00, 0xff, 0xab, 0x10] + #expect(OracleCellFormatting.hexEncode(bytes) == "00ffab10") + } + + @Test("Hex encode is empty for empty input") + func hexEncodeEmpty() { + #expect(OracleCellFormatting.hexEncode([]) == "") + } + + @Test("Hex encode truncates beyond 4 KB and reports total size") + func hexEncodeTruncates() { + let bytes = [UInt8](repeating: 0xab, count: 5_000) + let result = OracleCellFormatting.hexEncode(bytes) + #expect(result.hasSuffix("… (5000 bytes)")) + let hexPart = result.replacingOccurrences(of: "… (5000 bytes)", with: "") + #expect(hexPart.count == OracleCellFormatting.maxHexBytes * 2) + } + + @Test("Unsupported placeholder embeds the type name verbatim") + func unsupportedPlaceholder() { + #expect(OracleCellFormatting.unsupportedPlaceholder(typeName: "interval year to month") + == "") + } +} diff --git a/docs/databases/oracle.mdx b/docs/databases/oracle.mdx index 7b292f7d1..91fedc3a1 100644 --- a/docs/databases/oracle.mdx +++ b/docs/databases/oracle.mdx @@ -94,6 +94,26 @@ ORDER BY table_name; **Authentication**: Username/password only (no OS auth or wallet). Create user: `CREATE USER app_user IDENTIFIED BY "Password1!"; GRANT CREATE SESSION, SELECT ANY TABLE TO app_user;` +## Column Type Support + +| Oracle type | Display | +|---|---| +| VARCHAR2, NVARCHAR2, CHAR, NCHAR, LONG, CLOB, NCLOB, JSON | Text as stored | +| NUMBER | Exact integer up to Int64 range; decimals up to 17 digits of precision | +| BINARY_FLOAT, BINARY_DOUBLE | Native Swift Float/Double | +| DATE | `yyyy-MM-dd` | +| TIMESTAMP | ISO-8601 in UTC with fractional seconds | +| TIMESTAMP WITH TIME ZONE | ISO-8601 in the host's local time zone with explicit offset | +| TIMESTAMP WITH LOCAL TIME ZONE | ISO-8601 in the host's local time zone with explicit offset | +| INTERVAL DAY TO SECOND | `D HH:MM:SS` plus fractional seconds when non-zero (up to 9 digits, trailing zeros trimmed) | +| INTERVAL YEAR TO MONTH | `Y-MM` | +| RAW, LONG RAW, BLOB | Lowercase hex, truncated to 4 KB | +| ROWID | As stored | +| BOOLEAN | `true` / `false` | +| BFILE | `` placeholder (locator only) | + +Columns with types not yet supported render as `` rather than crashing or corrupting data. Report unsupported types via [GitHub Issues](https://github.com/TableProApp/TablePro/issues). + ## Troubleshooting **Connection refused**: Check listener running (`lsnrctl status`), verify port 1521 open, if Docker check `docker start oracle-xe` @@ -102,4 +122,4 @@ ORDER BY table_name; **Instant Client not found**: Download Basic package, extract to `/usr/local/oracle/instantclient`, set `DYLD_LIBRARY_PATH` -**Limitations**: Username/password only, LONG/LONG RAW limited (use CLOB/BLOB), PL/SQL limited to anonymous blocks. +**Limitations**: Username/password only, BFILE shows locator metadata only (content fetch via DBMS_LOB not supported), PL/SQL limited to anonymous blocks.