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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<unsupported: type>` instead of crashing (#965)

## [0.37.0] - 2026-05-01

### Added
Expand Down
108 changes: 108 additions & 0 deletions Plugins/OracleDriverPlugin/OracleCellFormatting.swift
Original file line number Diff line number Diff line change
@@ -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"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve time when rendering Oracle DATE values

Oracle DATE stores hours/minutes/seconds, but this formatter hard-codes yyyy-MM-dd in UTC, so any non-midnight DATE value loses its time component and can even show the wrong calendar day after UTC conversion. This is a data-accuracy regression for common schemas that use DATE as a full datetime, because users will see truncated/misaligned values rather than what is stored.

Useful? React with 👍 / 👎.

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 {
"<unsupported: \(typeName)>"
}
}
119 changes: 109 additions & 10 deletions Plugins/OracleDriverPlugin/OracleConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,18 @@ private actor QueryGate {
}
}

// MARK: - Unsupported Type Warner

private actor UnsupportedTypeWarner {
private var seen: Set<String> = []

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 {
Expand Down Expand Up @@ -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 "<bfile>"

case .cursor:
return "<cursor>"

case .vector:
return "<vector>"

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 "<decode error>"
}
}

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
}
Expand Down
Loading
Loading