Skip to content

Commit 37198b1

Browse files
committed
Support fetching TIMESTAMPTZ as SQL_TYPE_TIMESTAMP
DuckDB supports `TIMESTAMP WITH TIME ZONE` data type that stores UTC dates (with specified time zone already applied on DB insert). ODBC does not have a notion of time zones. When client app reads such `TIMESTAMP WITH TIME ZONE` it needs to be converted into `TIMESTAMP WITHOUT TIME ZONE` with client-local time zone applied. And such converted timestamp must represent the same moment of time as stored UTC timestamp. Applying client-local time zone to timestamp is a non-trivial operation - the UTC offset depends on both the time zone (for fixed shift) and on the timestamp value (for DST shift). There are two options to get the effective offset for the specified UTC timestamp in the specified time zone: - DuckDB ICU extension, that will use DB-configured time zone - OS API, will use OS-configured time zone Variant with ICU extension appeared to be problematic, as we would like to not link neither to ICU extension (C++ API, not in DuckDB C API) nor to ICU itself (C API exists, but symbols include ICU major version that can change). We can do ICU call from ODBC by creating SQL string and executing it. But this conversion must be performed for every value in every row. And there no reasonbly-easy ways to cache the results. Thus the idea of SQL calls was rejected. Variant with OS API matches the approach used in JDBC (see duckdb/duckdb-java#166) where JVM-configured time zone is used (invariant 1 there). The problem with accessing OS API is that `<ctime>` utilities are not thread-safe until `C++20`. Thus Windows `<timezoneapi.h>` and POSIX `<time.h>` API are used directly. And `<ctime>` approach is used in tests for cross-checking. Testing: new test added that fetches `TIMESTAMP WITH TIME ZONE` value as `SQL_C_TYPE_TIMESTAMP` and checks that it is shifted correctly.
1 parent aa7d4a8 commit 37198b1

File tree

5 files changed

+252
-5
lines changed

5 files changed

+252
-5
lines changed

include/odbc_utils.hpp

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
// needs to be first because BOOL
55
#include "duckdb.hpp"
66

7+
#include <cstdint>
78
#include <cstring>
89

910
#ifdef _WIN32
@@ -92,6 +93,8 @@ struct OdbcUtils {
9293
static SQLUINTEGER SQLPointerToSQLUInteger(SQLPOINTER value);
9394
static std::string ConvertSQLCHARToString(SQLCHAR *str);
9495
static LPCSTR ConvertStringToLPCSTR(const std::string &str);
96+
97+
static int64_t GetUTCOffsetMicrosFromOS(HSTMT hstmt, int64_t utc_micros);
9598
};
9699
} // namespace duckdb
97100
#endif

src/api_info.cpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,8 @@ SQLSMALLINT ApiInfo::FindRelatedSQLType(duckdb::LogicalTypeId type_id) {
264264
return SQL_TYPE_DATE;
265265
case LogicalTypeId::TIMESTAMP:
266266
return SQL_TYPE_TIMESTAMP;
267+
case LogicalTypeId::TIMESTAMP_TZ:
268+
return SQL_TYPE_TIMESTAMP;
267269
case LogicalTypeId::TIME:
268270
return SQL_TYPE_TIME;
269271
case LogicalTypeId::VARCHAR:

src/common/odbc_utils.cpp

Lines changed: 145 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,17 @@
33
#include "sqlext.h"
44

55
#include <sql.h>
6+
7+
#include <mutex>
68
#include <regex>
9+
10+
#ifndef _WIN32
11+
#include <time.h>
12+
#endif //!_WIN32
13+
714
#include "duckdb/common/vector.hpp"
815

16+
#include "handle_functions.hpp"
917
#include "widechar.hpp"
1018

1119
using duckdb::OdbcUtils;
@@ -105,7 +113,6 @@ string OdbcUtils::GetQueryDuckdbColumns(const string &catalog_filter, const stri
105113
'HUGEINT': 8, -- SQL_DOUBLE
106114
'DOUBLE': 8, -- SQL_DOUBLE
107115
'DATE': 91, -- SQL_TYPE_DATE
108-
'TIMESTAMP': 93, -- SQL_TYPE_TIMESTAMP
109116
'TIME': 92, -- SQL_TYPE_TIME
110117
'VARCHAR': 12, -- SQL_VARCHAR
111118
'BLOB': -3, -- SQL_VARBINARY
@@ -116,11 +123,15 @@ string OdbcUtils::GetQueryDuckdbColumns(const string &catalog_filter, const stri
116123
} AS mapping,
117124
STRING_SPLIT(data_type, '(')[1] AS data_type_no_typmod,
118125
CASE
126+
-- value 93 is SQL_TIMESTAMP
127+
-- TODO: investigate why map key with spaces and regexes don't work
128+
WHEN data_type LIKE 'TIMESTAMP%' THEN 93::SMALLINT
119129
WHEN mapping[data_type_no_typmod] IS NOT NULL THEN mapping[data_type_no_typmod]::SMALLINT
120130
ELSE data_type_id::SMALLINT
121131
END AS "DATA_TYPE",
122132
CASE
123133
WHEN data_type_no_typmod = 'DECIMAL' THEN 'NUMERIC'
134+
WHEN data_type LIKE 'TIMESTAMP%' THEN 'TIMESTAMP'
124135
ELSE data_type_no_typmod
125136
END AS "TYPE_NAME",
126137
CASE
@@ -139,8 +150,8 @@ string OdbcUtils::GetQueryDuckdbColumns(const string &catalog_filter, const stri
139150
END AS "COLUMN_SIZE",
140151
CASE
141152
WHEN data_type='DATE' THEN 4
142-
WHEN data_type='TIME' THEN 8
143153
WHEN data_type LIKE 'TIMESTAMP%' THEN 8
154+
WHEN data_type LIKE 'TIME%' THEN 8
144155
WHEN data_type='CHAR'
145156
OR data_type='BOOLEAN' THEN 1
146157
WHEN data_type='VARCHAR'
@@ -165,13 +176,14 @@ string OdbcUtils::GetQueryDuckdbColumns(const string &catalog_filter, const stri
165176
'' AS "REMARKS",
166177
column_default AS "COLUMN_DEF",
167178
CASE
179+
WHEN data_type LIKE 'TIMESTAMP%' THEN 93::SMALLINT
168180
WHEN mapping[data_type_no_typmod] IS NOT NULL THEN mapping[data_type_no_typmod]::SMALLINT
169181
ELSE data_type_id::SMALLINT
170182
END AS "SQL_DATA_TYPE",
171183
CASE
172-
WHEN data_type='DATE'
173-
OR data_type='TIME'
174-
OR data_type LIKE 'TIMESTAMP%' THEN data_type_id::SMALLINT
184+
WHEN data_type='DATE' then 1::SMALLINT
185+
WHEN data_type LIKE 'TIMESTAMP%' THEN 3::SMALLINT
186+
WHEN data_type LIKE 'TIME%' THEN 2::SMALLINT
175187
ELSE NULL::SMALLINT
176188
END AS "SQL_DATETIME_SUB",
177189
CASE
@@ -304,3 +316,131 @@ void duckdb::OdbcUtils::WriteString(const std::vector<SQLCHAR> &utf8_vec, std::s
304316
SQLLEN buf_len_bytes, SQLINTEGER *out_len_bytes) {
305317
return WriteStringInternal(utf8_vec.data(), utf8_vec_len, out_buf, buf_len_bytes, out_len_bytes);
306318
}
319+
320+
int64_t duckdb::OdbcUtils::GetUTCOffsetMicrosFromOS(HSTMT hstmt_ptr, int64_t utc_micros) {
321+
322+
// Casting here to not complicate header dependencies
323+
auto hstmt = reinterpret_cast<duckdb::OdbcHandleStmt *>(hstmt_ptr);
324+
325+
#ifdef _WIN32
326+
327+
// Convert microseconds to seconds for SYSTEMTIME
328+
int64_t seconds_since_epoch = utc_micros / 1000000;
329+
330+
// Convert seconds to FILETIME
331+
FILETIME ft_utc;
332+
ULARGE_INTEGER utc_time;
333+
// Add Windows epoch offset
334+
utc_time.QuadPart = static_cast<uint64_t>(seconds_since_epoch) * 10000000 + 11644473600LL * 10000000;
335+
ft_utc.dwLowDateTime = utc_time.LowPart;
336+
ft_utc.dwHighDateTime = utc_time.HighPart;
337+
338+
// Convert FILETIME to SYSTEMTIME
339+
SYSTEMTIME utc_system_time;
340+
auto res_fttt = FileTimeToSystemTime(&ft_utc, &utc_system_time);
341+
if (res_fttt == 0) {
342+
std::string msg = "FileTimeToSystemTime failed, input value: " + std::to_string(utc_micros) +
343+
", error code: " + std::to_string(GetLastError());
344+
duckdb::SetDiagnosticRecord(hstmt, SQL_ERROR, "timezone", msg, duckdb::SQLStateType::ST_HY000,
345+
hstmt->dbc->GetDataSourceName());
346+
return 0;
347+
}
348+
349+
// Get the default time zone information
350+
TIME_ZONE_INFORMATION tz_info;
351+
DWORD res_tzinfo = GetTimeZoneInformation(&tz_info);
352+
if (res_tzinfo == TIME_ZONE_ID_INVALID) {
353+
std::string msg = "GetTimeZoneInformation failed, input value: " + std::to_string(utc_micros) +
354+
", error code: " + std::to_string(GetLastError());
355+
duckdb::SetDiagnosticRecord(hstmt, SQL_ERROR, "timezone", msg, duckdb::SQLStateType::ST_HY000,
356+
hstmt->dbc->GetDataSourceName());
357+
return 0;
358+
}
359+
360+
// Convert UTC SYSTEMTIME to local SYSTEMTIME
361+
SYSTEMTIME local_system_time;
362+
auto res_tzspec = SystemTimeToTzSpecificLocalTime(&tz_info, &utc_system_time, &local_system_time);
363+
if (res_tzspec == 0) {
364+
std::string msg = "SystemTimeToTzSpecificLocalTime failed, input value: " + std::to_string(utc_micros) +
365+
", error code: " + std::to_string(GetLastError());
366+
duckdb::SetDiagnosticRecord(hstmt, SQL_ERROR, "timezone", msg, duckdb::SQLStateType::ST_HY000,
367+
hstmt->dbc->GetDataSourceName());
368+
return 0;
369+
}
370+
371+
// Convert local SYSTEMTIME back to FILETIME
372+
FILETIME ft_local;
373+
auto res_sttft = SystemTimeToFileTime(&local_system_time, &ft_local);
374+
if (res_sttft == 0) {
375+
std::string msg = "SystemTimeToFileTime failed, input value: " + std::to_string(utc_micros) +
376+
", error code: " + std::to_string(GetLastError());
377+
duckdb::SetDiagnosticRecord(hstmt, SQL_ERROR, "timezone", msg, duckdb::SQLStateType::ST_HY000,
378+
hstmt->dbc->GetDataSourceName());
379+
return 0;
380+
}
381+
382+
// Convert both FILETIMEs to ULARGE_INTEGER for arithmetic
383+
ULARGE_INTEGER local_time;
384+
local_time.LowPart = ft_local.dwLowDateTime;
385+
local_time.HighPart = ft_local.dwHighDateTime;
386+
387+
// Calculate the offset in microseconds
388+
return static_cast<int64_t>(local_time.QuadPart - utc_time.QuadPart) / 10;
389+
390+
#else //!_WiN32
391+
392+
// Convert microseconds to seconds
393+
time_t utc_seconds_since_epoch = utc_micros / 1000000;
394+
395+
// Break down UTC time into a tm struct
396+
struct tm utc_time_struct;
397+
// Thread-safe UTC conversion
398+
auto res_gmt = gmtime_r(&utc_seconds_since_epoch, &utc_time_struct);
399+
if (res_gmt == nullptr) {
400+
std::string msg =
401+
"gmtime_r failed, input value: " + std::to_string(utc_micros) + ", error code: " + std::to_string(errno);
402+
duckdb::SetDiagnosticRecord(hstmt, SQL_ERROR, "timezone", msg, duckdb::SQLStateType::ST_HY000,
403+
hstmt->dbc->GetDataSourceName());
404+
return 0;
405+
}
406+
407+
// Convert UTC time to local time in the default time zone
408+
struct tm local_time_struct;
409+
// Thread-safe local time conversion
410+
auto res_local = localtime_r(&utc_seconds_since_epoch, &local_time_struct);
411+
if (res_local == nullptr) {
412+
std::string msg =
413+
"localtime_r failed, input value: " + std::to_string(utc_micros) + ", error code: " + std::to_string(errno);
414+
duckdb::SetDiagnosticRecord(hstmt, SQL_ERROR, "timezone", msg, duckdb::SQLStateType::ST_HY000,
415+
hstmt->dbc->GetDataSourceName());
416+
return 0;
417+
}
418+
// DST confuses mktime
419+
local_time_struct.tm_isdst = 0;
420+
421+
// Convert broken-down times to time_t (seconds since epoch)
422+
time_t local_time = mktime(&local_time_struct);
423+
if (local_time == -1) {
424+
std::string msg = "mktime local failed, input value: " + std::to_string(utc_micros) +
425+
", error code: " + std::to_string(errno);
426+
duckdb::SetDiagnosticRecord(hstmt, SQL_ERROR, "timezone", msg, duckdb::SQLStateType::ST_HY000,
427+
hstmt->dbc->GetDataSourceName());
428+
return 0;
429+
}
430+
time_t utc_time = mktime(&utc_time_struct);
431+
if (utc_time == -1) {
432+
std::string msg =
433+
"mktime utc failed, input value: " + std::to_string(utc_micros) + ", error code: " + std::to_string(errno);
434+
duckdb::SetDiagnosticRecord(hstmt, SQL_ERROR, "timezone", msg, duckdb::SQLStateType::ST_HY000,
435+
hstmt->dbc->GetDataSourceName());
436+
return 0;
437+
}
438+
439+
// Calculate the offset in seconds
440+
int64_t offset_seconds = static_cast<int64_t>(difftime(local_time, utc_time));
441+
442+
// Convert offset to microseconds
443+
return offset_seconds * 1000000;
444+
445+
#endif // _WIN32
446+
}

src/statement/statement_functions.cpp

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,11 @@ SQLRETURN duckdb::GetDataStmtResult(OdbcHandleStmt *hstmt, SQLUSMALLINT col_or_p
332332
*str_len_or_ind_ptr = SQL_NULL_DATA;
333333
return SQL_SUCCESS;
334334
}
335+
if (val.type().id() == LogicalType::TIMESTAMP_TZ) {
336+
int64_t utc_micros = val.GetValue<int64_t>();
337+
int64_t utc_offset_micros = duckdb::OdbcUtils::GetUTCOffsetMicrosFromOS(hstmt, utc_micros);
338+
val = Value::TIMESTAMP(timestamp_t(utc_micros + utc_offset_micros));
339+
}
335340

336341
SQLSMALLINT target_type_resolved = target_type;
337342
if (target_type_resolved == SQL_C_DEFAULT) {

test/tests/test_timestamp.cpp

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
#include "odbc_test_common.h"
22

3+
#include <cstdint>
4+
#include <ctime>
5+
#include <iomanip>
6+
#include <mutex>
7+
#include <sstream>
8+
39
using namespace odbc_test;
410

511
TEST_CASE("Test SQLBindParameter with TIMESTAMP type", "[odbc]") {
@@ -168,3 +174,94 @@ TEST_CASE("Test SQLBindParameter with TIME type", "[odbc]") {
168174

169175
DISCONNECT_FROM_DATABASE(env, dbc);
170176
}
177+
178+
static std::mutex &get_static_mutex() {
179+
static std::mutex mtx;
180+
return mtx;
181+
}
182+
183+
static int64_t os_utc_offset_seconds(const std::string &timestamp_st) {
184+
std::mutex &mtx = get_static_mutex();
185+
std::lock_guard<std::mutex> guard(mtx);
186+
std::tm input_time = {};
187+
std::istringstream ss(timestamp_st);
188+
ss >> std::get_time(&input_time, "%Y-%m-%d %H:%M:%S");
189+
std::time_t input_instant = std::mktime(&input_time);
190+
std::tm local_time = *std::localtime(&input_instant);
191+
local_time.tm_isdst = 0;
192+
std::tm utc_time = *std::gmtime(&input_instant);
193+
std::time_t local_instant = std::mktime(&local_time);
194+
std::time_t utc_instant = std::mktime(&utc_time);
195+
return static_cast<int64_t>(difftime(local_instant, utc_instant));
196+
}
197+
198+
static std::tm sql_timestamp_to_tm(const SQL_TIMESTAMP_STRUCT &ts) {
199+
std::tm dt = {};
200+
dt.tm_year = ts.year - 1900;
201+
dt.tm_mon = ts.month - 1;
202+
dt.tm_mday = ts.day;
203+
dt.tm_hour = ts.hour;
204+
dt.tm_min = ts.minute;
205+
dt.tm_sec = ts.second;
206+
return dt;
207+
}
208+
209+
static int64_t sql_timestamp_diff_seconds(const SQL_TIMESTAMP_STRUCT &ts1, const SQL_TIMESTAMP_STRUCT &ts2) {
210+
std::mutex &mtx = get_static_mutex();
211+
std::lock_guard<std::mutex> guard(mtx);
212+
std::tm tm1 = sql_timestamp_to_tm(ts1);
213+
std::tm tm2 = sql_timestamp_to_tm(ts2);
214+
std::time_t instant1 = std::mktime(&tm1);
215+
std::time_t instant2 = std::mktime(&tm2);
216+
return static_cast<int64_t>(std::difftime(instant1, instant2));
217+
}
218+
219+
TEST_CASE("Test fetching TIMESTAMP_TZ values as TIMESTAMP", "[odbc]") {
220+
SQLHANDLE env;
221+
SQLHANDLE dbc;
222+
223+
HSTMT hstmt = SQL_NULL_HSTMT;
224+
225+
// Connect to the database using SQLConnect
226+
CONNECT_TO_DATABASE(env, dbc);
227+
228+
// Allocate a statement handle
229+
EXECUTE_AND_CHECK("SQLAllocHandle (HSTMT)", hstmt, SQLAllocHandle, SQL_HANDLE_STMT, dbc, &hstmt);
230+
231+
std::string tm_local_st = "2004-07-19 10:23:54";
232+
233+
// Process this date as TIMESTAMP_TZ and fetch is as SQL_C_TYPE_TIMESTAMP,
234+
// return value must be converted to local using OS-configured time-zone
235+
EXECUTE_AND_CHECK("SQLExecDirect", hstmt, SQLExecDirect, hstmt,
236+
ConvertToSQLCHAR("SELECT '" + tm_local_st + "+00'::TIMESTAMP WITH TIME ZONE"), SQL_NTS);
237+
EXECUTE_AND_CHECK("SQLFetch", hstmt, SQLFetch, hstmt);
238+
SQL_TIMESTAMP_STRUCT fetched_timestamp_tz;
239+
EXECUTE_AND_CHECK("SQLGetData", hstmt, SQLGetData, hstmt, 1, SQL_C_TYPE_TIMESTAMP, &fetched_timestamp_tz,
240+
sizeof(fetched_timestamp_tz), nullptr);
241+
242+
// Process the same date as TIMESTAMP, no time-zone conversion performed
243+
EXECUTE_AND_CHECK("SQLExecDirect", hstmt, SQLExecDirect, hstmt,
244+
ConvertToSQLCHAR("SELECT '" + tm_local_st + "'::TIMESTAMP WITHOUT TIME ZONE"), SQL_NTS);
245+
EXECUTE_AND_CHECK("SQLFetch", hstmt, SQLFetch, hstmt);
246+
SQL_TIMESTAMP_STRUCT fetched_timestamp;
247+
EXECUTE_AND_CHECK("SQLGetData", hstmt, SQLGetData, hstmt, 1, SQL_C_TYPE_TIMESTAMP, &fetched_timestamp,
248+
sizeof(fetched_timestamp), nullptr);
249+
REQUIRE(fetched_timestamp.year == 2004);
250+
REQUIRE(fetched_timestamp.month == 7);
251+
REQUIRE(fetched_timestamp.day == 19);
252+
REQUIRE(fetched_timestamp.hour == 10);
253+
REQUIRE(fetched_timestamp.minute == 23);
254+
REQUIRE(fetched_timestamp.second == 54);
255+
REQUIRE(fetched_timestamp.fraction == 0);
256+
257+
// Get OS time zone offset and compare the fetched results
258+
int64_t os_utc_offset = os_utc_offset_seconds(tm_local_st);
259+
int64_t fetched_diff = sql_timestamp_diff_seconds(fetched_timestamp_tz, fetched_timestamp);
260+
REQUIRE(fetched_diff == os_utc_offset);
261+
262+
// Free the statement handle
263+
EXECUTE_AND_CHECK("SQLFreeStmt (HSTMT)", hstmt, SQLFreeStmt, hstmt, SQL_CLOSE);
264+
EXECUTE_AND_CHECK("SQLFreeHandle (HSTMT)", hstmt, SQLFreeHandle, SQL_HANDLE_STMT, hstmt);
265+
266+
DISCONNECT_FROM_DATABASE(env, dbc);
267+
}

0 commit comments

Comments
 (0)