@@ -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
352507private 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