Skip to content

Commit 1ebf8bf

Browse files
authored
fix: DuckDB TIMESTAMPTZ and TIMETZ columns displaying as null (#424) (#427)
* fix: DuckDB TIMESTAMPTZ and TIMETZ columns displaying as null (#424) * fix: strip trailing semicolons from query before wrapping, remove extra blank line
1 parent 842e1fe commit 1ebf8bf

2 files changed

Lines changed: 160 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1818

1919
### Fixed
2020

21+
- DuckDB: TIMESTAMPTZ, TIMETZ, and other temporal columns displaying as null (#424)
2122
- Onboarding "Get Started" button not rendering on macOS 15 until window loses focus (#420)
2223
- MongoDB collection loading uses `estimatedDocumentCount` and smaller schema sample for faster sidebar population
2324

Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift

Lines changed: 159 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,9 @@ private actor DuckDBConnectionActor {
187187
duckdb_destroy_result(&result)
188188
}
189189

190-
return Self.extractResult(from: &result, startTime: startTime)
190+
var raw = Self.extractResult(from: &result, startTime: startTime)
191+
Self.patchTzColumns(&raw, query: query, connection: conn)
192+
return raw
191193
}
192194

193195
func executePrepared(_ query: String, parameters: [String?]) throws -> DuckDBRawResult {
@@ -251,7 +253,9 @@ private actor DuckDBConnectionActor {
251253
duckdb_destroy_result(&result)
252254
}
253255

254-
return Self.extractResult(from: &result, startTime: startTime)
256+
var raw = Self.extractResult(from: &result, startTime: startTime)
257+
Self.patchTzColumns(&raw, query: query, connection: conn)
258+
return raw
255259
}
256260

257261
private static func extractResult(
@@ -264,6 +268,7 @@ private actor DuckDBConnectionActor {
264268

265269
var columns: [String] = []
266270
var columnTypeNames: [String] = []
271+
var columnTypes: [duckdb_type] = []
267272

268273
for i in 0..<colCount {
269274
if let namePtr = duckdb_column_name(&result, i) {
@@ -273,6 +278,7 @@ private actor DuckDBConnectionActor {
273278
}
274279

275280
let colType = duckdb_column_type(&result, i)
281+
columnTypes.append(colType)
276282
columnTypeNames.append(Self.typeName(for: colType))
277283
}
278284

@@ -294,7 +300,7 @@ private actor DuckDBConnectionActor {
294300
rowData.append(String(cString: valPtr))
295301
duckdb_free(valPtr)
296302
} else {
297-
rowData.append(nil)
303+
rowData.append(Self.extractFallbackValue(&result, col: col, row: row, type: columnTypes[Int(col)]))
298304
}
299305
}
300306

@@ -344,15 +350,164 @@ private actor DuckDBConnectionActor {
344350
case DUCKDB_TYPE_UUID: return "UUID"
345351
case DUCKDB_TYPE_UNION: return "UNION"
346352
case DUCKDB_TYPE_BIT: return "BIT"
353+
case DUCKDB_TYPE_TIMESTAMP_TZ: return "TIMESTAMPTZ"
354+
case DUCKDB_TYPE_TIME_TZ: return "TIMETZ"
355+
case DUCKDB_TYPE_TIME_NS: return "TIME_NS"
356+
case DUCKDB_TYPE_UHUGEINT: return "UHUGEINT"
357+
case DUCKDB_TYPE_ARRAY: return "ARRAY"
347358
default: return "VARCHAR"
348359
}
349360
}
361+
362+
private static func extractFallbackValue(
363+
_ result: inout duckdb_result, col: idx_t, row: idx_t, type: duckdb_type
364+
) -> String? {
365+
switch type {
366+
case DUCKDB_TYPE_TIMESTAMP, DUCKDB_TYPE_TIMESTAMP_S, DUCKDB_TYPE_TIMESTAMP_MS, DUCKDB_TYPE_TIMESTAMP_NS:
367+
let ts = duckdb_value_timestamp(&result, col, row)
368+
return formatTimestamp(ts)
369+
370+
case DUCKDB_TYPE_DATE:
371+
let date = duckdb_value_date(&result, col, row)
372+
let d = duckdb_from_date(date)
373+
return String(format: "%04d-%02d-%02d", d.year, d.month, d.day)
374+
375+
case DUCKDB_TYPE_TIME, DUCKDB_TYPE_TIME_NS:
376+
let time = duckdb_value_time(&result, col, row)
377+
return formatTime(duckdb_from_time(time))
378+
379+
case DUCKDB_TYPE_BOOLEAN:
380+
return duckdb_value_boolean(&result, col, row) ? "true" : "false"
381+
382+
case DUCKDB_TYPE_TINYINT:
383+
return String(duckdb_value_int8(&result, col, row))
384+
case DUCKDB_TYPE_SMALLINT:
385+
return String(duckdb_value_int16(&result, col, row))
386+
case DUCKDB_TYPE_INTEGER:
387+
return String(duckdb_value_int32(&result, col, row))
388+
case DUCKDB_TYPE_BIGINT:
389+
return String(duckdb_value_int64(&result, col, row))
390+
case DUCKDB_TYPE_UTINYINT:
391+
return String(duckdb_value_uint8(&result, col, row))
392+
case DUCKDB_TYPE_USMALLINT:
393+
return String(duckdb_value_uint16(&result, col, row))
394+
case DUCKDB_TYPE_UINTEGER:
395+
return String(duckdb_value_uint32(&result, col, row))
396+
case DUCKDB_TYPE_UBIGINT:
397+
return String(duckdb_value_uint64(&result, col, row))
398+
case DUCKDB_TYPE_FLOAT:
399+
return String(duckdb_value_float(&result, col, row))
400+
case DUCKDB_TYPE_DOUBLE:
401+
return String(duckdb_value_double(&result, col, row))
402+
403+
case DUCKDB_TYPE_HUGEINT:
404+
let h = duckdb_value_hugeint(&result, col, row)
405+
return formatHugeInt(upper: h.upper, lower: h.lower)
406+
407+
case DUCKDB_TYPE_UHUGEINT:
408+
let u = duckdb_value_uhugeint(&result, col, row)
409+
return formatUHugeInt(upper: u.upper, lower: u.lower)
410+
411+
default:
412+
return nil
413+
}
414+
}
415+
416+
/// DuckDB v1.5.0 C API: duckdb_value_varchar returns nil for TIMESTAMPTZ and TIMETZ,
417+
/// and duckdb_value_is_null is unreliable for these types. The only reliable method
418+
/// is re-executing the query with TZ columns cast to VARCHAR at the SQL level.
419+
private static func patchTzColumns(
420+
_ raw: inout DuckDBRawResult, query: String, connection: duckdb_connection
421+
) {
422+
let tzTypes: Set<String> = ["TIMESTAMPTZ", "TIMETZ"]
423+
let tzColIndices = raw.columnTypeNames.enumerated().compactMap { idx, name in
424+
tzTypes.contains(name) ? idx : nil
425+
}
426+
guard !tzColIndices.isEmpty, !raw.rows.isEmpty else { return }
427+
428+
var castExprs: [String] = []
429+
for (i, name) in raw.columns.enumerated() {
430+
let escaped = name.replacingOccurrences(of: "\"", with: "\"\"")
431+
if tzColIndices.contains(i) {
432+
castExprs.append(
433+
"CASE WHEN \"\(escaped)\" IS NULL THEN NULL ELSE CAST(\"\(escaped)\" AS VARCHAR) END AS \"\(escaped)\""
434+
)
435+
} else {
436+
castExprs.append("\"\(escaped)\"")
437+
}
438+
}
439+
440+
let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
441+
.hasSuffix(";") ? String(query.dropLast()) : query
442+
let wrappedQuery = "SELECT \(castExprs.joined(separator: ", ")) FROM (\(trimmedQuery)) AS _tz_cast"
443+
var patchResult = duckdb_result()
444+
guard duckdb_query(connection, wrappedQuery, &patchResult) == DuckDBSuccess else { return }
445+
defer { duckdb_destroy_result(&patchResult) }
446+
447+
let patchRowCount = min(duckdb_row_count(&patchResult), UInt64(raw.rows.count))
448+
for row in 0..<patchRowCount {
449+
for colIdx in tzColIndices {
450+
if duckdb_value_is_null(&patchResult, idx_t(colIdx), row) {
451+
raw.rows[Int(row)][colIdx] = nil
452+
} else if let ptr = duckdb_value_varchar(&patchResult, idx_t(colIdx), row) {
453+
raw.rows[Int(row)][colIdx] = String(cString: ptr)
454+
duckdb_free(ptr)
455+
}
456+
}
457+
}
458+
}
459+
460+
private static func formatTimestamp(_ ts: duckdb_timestamp) -> String {
461+
let parts = duckdb_from_timestamp(ts)
462+
let d = parts.date
463+
let t = parts.time
464+
let micros = t.micros % 1_000_000
465+
if micros == 0 {
466+
return String(
467+
format: "%04d-%02d-%02d %02d:%02d:%02d",
468+
d.year, d.month, d.day, t.hour, t.min, t.sec
469+
)
470+
}
471+
return String(
472+
format: "%04d-%02d-%02d %02d:%02d:%02d.%06d",
473+
d.year, d.month, d.day, t.hour, t.min, t.sec, micros
474+
)
475+
}
476+
477+
private static func formatTime(_ t: duckdb_time_struct) -> String {
478+
let micros = t.micros % 1_000_000
479+
if micros == 0 {
480+
return String(format: "%02d:%02d:%02d", t.hour, t.min, t.sec)
481+
}
482+
return String(format: "%02d:%02d:%02d.%06d", t.hour, t.min, t.sec, micros)
483+
}
484+
485+
private static func formatHugeInt(upper: Int64, lower: UInt64) -> String {
486+
if upper == 0 {
487+
return String(lower)
488+
}
489+
if upper == -1, lower > Int64.max.magnitude {
490+
let val = ~upper
491+
let low = ~lower &+ 1
492+
return "-\(formatUHugeInt(upper: UInt64(val), lower: low))"
493+
}
494+
return formatUHugeInt(upper: UInt64(upper), lower: lower)
495+
}
496+
497+
private static func formatUHugeInt(upper: UInt64, lower: UInt64) -> String {
498+
if upper == 0 {
499+
return String(lower)
500+
}
501+
let upperDecimal = Decimal(upper) * Decimal(sign: .plus, exponent: 0, significand: Decimal(UInt64.max) + 1)
502+
let result = upperDecimal + Decimal(lower)
503+
return "\(result)"
504+
}
350505
}
351506

352507
private struct DuckDBRawResult: Sendable {
353508
let columns: [String]
354509
let columnTypeNames: [String]
355-
let rows: [[String?]]
510+
var rows: [[String?]]
356511
let rowsAffected: Int
357512
let executionTime: TimeInterval
358513
let isTruncated: Bool

0 commit comments

Comments
 (0)