Skip to content

Commit 98b1ff6

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 88e79d7 commit 98b1ff6

File tree

5 files changed

+252
-5
lines changed

5 files changed

+252
-5
lines changed

include/odbc_utils.hpp

+3
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
@@ -93,6 +94,8 @@ struct OdbcUtils {
9394
static std::string ConvertSQLCHARToString(SQLCHAR *str);
9495
static LPCSTR ConvertStringToLPCSTR(const std::string &str);
9596
static SQLCHAR *ConvertStringToSQLCHAR(const std::string &str);
97+
98+
static int64_t GetUTCOffsetMicrosFromOS(HSTMT hstmt, int64_t utc_micros);
9699
};
97100
} // namespace duckdb
98101
#endif

src/api_info.cpp

+2
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,8 @@ SQLSMALLINT ApiInfo::FindRelatedSQLType(duckdb::LogicalTypeId type_id) {
267267
return SQL_TYPE_DATE;
268268
case LogicalTypeId::TIMESTAMP:
269269
return SQL_TYPE_TIMESTAMP;
270+
case LogicalTypeId::TIMESTAMP_TZ:
271+
return SQL_TYPE_TIMESTAMP;
270272
case LogicalTypeId::TIME:
271273
return SQL_TYPE_TIME;
272274
case LogicalTypeId::VARCHAR:

src/common/odbc_utils.cpp

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

src/statement/statement_functions.cpp

+5
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

+97
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)