Skip to content

Commit bbcd42b

Browse files
committed
working datetimeoffset
1 parent d521601 commit bbcd42b

File tree

5 files changed

+178
-29
lines changed

5 files changed

+178
-29
lines changed

main.py

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,62 @@
22
from mssql_python import setup_logging
33
import os
44
import decimal
5+
# import pyodbc
56

67
setup_logging('stdout')
78

8-
conn_str = os.getenv("DB_CONNECTION_STRING")
9+
# conn_str = os.getenv("DB_CONNECTION_STRING")
10+
conn_str = "DRIVER={ODBC Driver 18 for SQL Server};Server=Saumya;DATABASE=master;UID=sa;PWD=HappyPass1234;Trust_Connection=yes;TrustServerCertificate=yes;"
11+
912
conn = connect(conn_str)
1013

11-
# conn.autocommit = True
14+
# # conn.autocommit = True
15+
16+
# cursor = conn.cursor()
17+
# cursor.execute("SELECT database_id, name from sys.databases;")
18+
# rows = cursor.fetchall()
19+
20+
# for row in rows:
21+
# print(f"Database ID: {row[0]}, Name: {row[1]}")
22+
23+
# cursor.close()
24+
# conn.close()
25+
26+
from datetime import datetime, timezone, timedelta
1227

28+
# Connect and get cursor
1329
cursor = conn.cursor()
14-
cursor.execute("SELECT database_id, name from sys.databases;")
15-
rows = cursor.fetchall()
1630

17-
for row in rows:
18-
print(f"Database ID: {row[0]}, Name: {row[1]}")
31+
# Create table (drop if exists)
32+
cursor.execute("""
33+
IF OBJECT_ID('dbo.test_datetimeoffset', 'U') IS NOT NULL
34+
DROP TABLE dbo.test_datetimeoffset;
35+
36+
CREATE TABLE dbo.test_datetimeoffset (
37+
id INT PRIMARY KEY,
38+
dt DATETIMEOFFSET
39+
)
40+
""")
41+
42+
# Insert a row
43+
dt_offset = datetime(2025, 9, 9, 15, 30, 45, 123456, tzinfo=timezone(timedelta(hours=5, minutes=30)))
44+
cursor.execute("INSERT INTO test_datetimeoffset (id, dt) VALUES (?, ?)", 1, dt_offset)
45+
conn.commit()
46+
print("Insertion done. Verify in SSMS.")
47+
48+
# --- Fetch the row ---
49+
cursor.execute("SELECT id, dt FROM dbo.test_datetimeoffset WHERE id = ?", 1)
50+
row = cursor.fetchone()
51+
52+
if row:
53+
fetched_id, fetched_dt = row
54+
print(f"Fetched ID: {fetched_id}")
55+
print(f"Fetched DATETIMEOFFSET: {fetched_dt} (tzinfo: {fetched_dt.tzinfo})")
1956

20-
cursor.close()
21-
conn.close()
57+
# Optional check
58+
if fetched_dt == dt_offset:
59+
print("✅ Fetch successful: Datetime matches inserted value")
60+
else:
61+
print("⚠️ Fetch mismatch: Datetime does not match inserted value")
62+
else:
63+
print("No row fetched.")

mssql_python/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ class ConstantsDDBC(Enum):
124124
SQL_FETCH_ABSOLUTE = 5
125125
SQL_FETCH_RELATIVE = 6
126126
SQL_FETCH_BOOKMARK = 8
127+
SQL_DATETIMEOFFSET = -155
127128

128129
class AuthType(Enum):
129130
"""Constants for authentication types"""

mssql_python/cursor.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -431,13 +431,24 @@ def _map_sql_type(self, param, parameters_list, i, min_val=None, max_val=None):
431431
)
432432

433433
if isinstance(param, datetime.datetime):
434-
return (
435-
ddbc_sql_const.SQL_TIMESTAMP.value,
436-
ddbc_sql_const.SQL_C_TYPE_TIMESTAMP.value,
437-
26,
438-
6,
439-
False,
440-
)
434+
if param.tzinfo is not None:
435+
# Timezone-aware datetime -> DATETIMEOFFSET
436+
return (
437+
ddbc_sql_const.SQL_DATETIMEOFFSET.value,
438+
ddbc_sql_const.SQL_C_TYPE_TIMESTAMP.value,
439+
34,
440+
7,
441+
False,
442+
)
443+
else:
444+
# Naive datetime -> TIMESTAMP
445+
return (
446+
ddbc_sql_const.SQL_TIMESTAMP.value,
447+
ddbc_sql_const.SQL_C_TYPE_TIMESTAMP.value,
448+
26,
449+
6,
450+
False,
451+
)
441452

442453
if isinstance(param, datetime.date):
443454
return (

mssql_python/msvcp140.dll

562 KB
Binary file not shown.

mssql_python/pybind/ddbc_bindings.cpp

Lines changed: 109 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,14 @@
1212
#include <iostream>
1313
#include <utility> // std::forward
1414
#include <filesystem>
15-
1615
//-------------------------------------------------------------------------------------------------
1716
// Macro definitions
1817
//-------------------------------------------------------------------------------------------------
1918

2019
// This constant is not exposed via sql.h, hence define it here
2120
#define SQL_SS_TIME2 (-154)
21+
#define SQL_SS_TIMESTAMPOFFSET (-155)
22+
#define SQL_C_SS_TIMESTAMPOFFSET (16385)
2223

2324
#define MAX_DIGITS_IN_NUMERIC 64
2425

@@ -94,6 +95,19 @@ struct ColumnBuffers {
9495
indicators(numCols, std::vector<SQLLEN>(fetchSize)) {}
9596
};
9697

98+
struct SQL_SS_TIMESTAMPOFFSET_STRUCT
99+
{
100+
SQLSMALLINT year;
101+
SQLUSMALLINT month;
102+
SQLUSMALLINT day;
103+
SQLUSMALLINT hour;
104+
SQLUSMALLINT minute;
105+
SQLUSMALLINT second;
106+
SQLUINTEGER fraction; // Nanoseconds
107+
SQLSMALLINT timezone_hour; // Offset hours from UTC
108+
SQLSMALLINT timezone_minute; // Offset minutes from UTC
109+
};
110+
97111
//-------------------------------------------------------------------------------------------------
98112
// Function pointer initialization
99113
//-------------------------------------------------------------------------------------------------
@@ -460,18 +474,51 @@ SQLRETURN BindParameters(SQLHANDLE hStmt, const py::list& params,
460474
if (!py::isinstance(param, datetimeType)) {
461475
ThrowStdException(MakeParamMismatchErrorStr(paramInfo.paramCType, paramIndex));
462476
}
463-
SQL_TIMESTAMP_STRUCT* sqlTimestampPtr =
464-
AllocateParamBuffer<SQL_TIMESTAMP_STRUCT>(paramBuffers);
465-
sqlTimestampPtr->year = static_cast<SQLSMALLINT>(param.attr("year").cast<int>());
466-
sqlTimestampPtr->month = static_cast<SQLUSMALLINT>(param.attr("month").cast<int>());
467-
sqlTimestampPtr->day = static_cast<SQLUSMALLINT>(param.attr("day").cast<int>());
468-
sqlTimestampPtr->hour = static_cast<SQLUSMALLINT>(param.attr("hour").cast<int>());
469-
sqlTimestampPtr->minute = static_cast<SQLUSMALLINT>(param.attr("minute").cast<int>());
470-
sqlTimestampPtr->second = static_cast<SQLUSMALLINT>(param.attr("second").cast<int>());
471-
// SQL server supports in ns, but python datetime supports in µs
472-
sqlTimestampPtr->fraction = static_cast<SQLUINTEGER>(
473-
param.attr("microsecond").cast<int>() * 1000); // Convert µs to ns
474-
dataPtr = static_cast<void*>(sqlTimestampPtr);
477+
if (paramInfo.paramSQLType == SQL_TIMESTAMP) {
478+
// Handle naive datetime
479+
SQL_TIMESTAMP_STRUCT* tsPtr = AllocateParamBuffer<SQL_TIMESTAMP_STRUCT>(paramBuffers);
480+
tsPtr->year = static_cast<SQLSMALLINT>(param.attr("year").cast<int>());
481+
tsPtr->month = static_cast<SQLUSMALLINT>(param.attr("month").cast<int>());
482+
tsPtr->day = static_cast<SQLUSMALLINT>(param.attr("day").cast<int>());
483+
tsPtr->hour = static_cast<SQLUSMALLINT>(param.attr("hour").cast<int>());
484+
tsPtr->minute = static_cast<SQLUSMALLINT>(param.attr("minute").cast<int>());
485+
tsPtr->second = static_cast<SQLUSMALLINT>(param.attr("second").cast<int>());
486+
tsPtr->fraction = static_cast<SQLUINTEGER>(param.attr("microsecond").cast<int>() * 1000);
487+
dataPtr = static_cast<void*>(tsPtr);
488+
}
489+
else if (paramInfo.paramSQLType == SQL_SS_TIMESTAMPOFFSET) {
490+
// Handle tz-aware datetime → SQL_DATETIMEOFFSET
491+
SQL_SS_TIMESTAMPOFFSET_STRUCT* dtoPtr = AllocateParamBuffer<SQL_SS_TIMESTAMPOFFSET_STRUCT>(paramBuffers);
492+
int year = param.attr("year").cast<int>();
493+
if (year < 1753 || year > 9999) {
494+
ThrowStdException("Date out of range for SQL Server (1753-9999) at paramIndex " + std::to_string(paramIndex));
495+
}
496+
dtoPtr->year = static_cast<SQLSMALLINT>(year);
497+
dtoPtr->month = static_cast<SQLUSMALLINT>(param.attr("month").cast<int>());
498+
dtoPtr->day = static_cast<SQLUSMALLINT>(param.attr("day").cast<int>());
499+
dtoPtr->hour = static_cast<SQLUSMALLINT>(param.attr("hour").cast<int>());
500+
dtoPtr->minute = static_cast<SQLUSMALLINT>(param.attr("minute").cast<int>());
501+
dtoPtr->second = static_cast<SQLUSMALLINT>(param.attr("second").cast<int>());
502+
dtoPtr->fraction = static_cast<SQLUINTEGER>(param.attr("microsecond").cast<int>() * 1000);
503+
504+
py::object tzinfo = param.attr("tzinfo");
505+
if (tzinfo.is_none()) {
506+
ThrowStdException("Datetime object must have tzinfo for DATETIMEOFFSET at paramIndex " + std::to_string(paramIndex));
507+
}
508+
509+
py::object utcoffset = tzinfo.attr("utcoffset")(param);
510+
if (utcoffset.is_none()) {
511+
ThrowStdException("utcoffset is None for DATETIMEOFFSET at paramIndex " + std::to_string(paramIndex));
512+
}
513+
514+
int total_seconds = static_cast<int>(utcoffset.attr("total_seconds")().cast<double>());
515+
dtoPtr->timezone_hour = static_cast<SQLSMALLINT>(total_seconds / 3600);
516+
dtoPtr->timezone_minute = static_cast<SQLSMALLINT>((abs(total_seconds) % 3600) / 60);
517+
dataPtr = static_cast<void*>(dtoPtr);
518+
}
519+
else {
520+
ThrowStdException("Unsupported SQL type for timestamp at paramIndex " + std::to_string(paramIndex));
521+
}
475522
break;
476523
}
477524
case SQL_C_NUMERIC: {
@@ -506,7 +553,6 @@ SQLRETURN BindParameters(SQLHANDLE hStmt, const py::list& params,
506553
}
507554
}
508555
assert(SQLBindParameter_ptr && SQLGetStmtAttr_ptr && SQLSetDescField_ptr);
509-
510556
RETCODE rc = SQLBindParameter_ptr(
511557
hStmt,
512558
static_cast<SQLUSMALLINT>(paramIndex + 1), /* 1-based indexing */
@@ -2180,6 +2226,55 @@ SQLRETURN SQLGetData_wrap(SqlHandlePtr StatementHandle, SQLUSMALLINT colCount, p
21802226
}
21812227
break;
21822228
}
2229+
case SQL_SS_TIMESTAMPOFFSET: {
2230+
SQL_SS_TIMESTAMPOFFSET_STRUCT dtoValue;
2231+
ret = SQLGetData_ptr(
2232+
hStmt,
2233+
i,
2234+
SQL_C_SS_TIMESTAMPOFFSET,
2235+
&dtoValue,
2236+
sizeof(dtoValue),
2237+
NULL
2238+
);
2239+
if (SQL_SUCCEEDED(ret)) {
2240+
LOG("[Fetch] Retrieved DTO: {}-{}-{} {}:{}:{}, fraction(ns)={}, tz_hour={}, tz_minute={}",
2241+
dtoValue.year, dtoValue.month, dtoValue.day,
2242+
dtoValue.hour, dtoValue.minute, dtoValue.second,
2243+
dtoValue.fraction,
2244+
dtoValue.timezone_hour, dtoValue.timezone_minute
2245+
);
2246+
2247+
int totalMinutes = dtoValue.timezone_hour * 60 + dtoValue.timezone_minute;
2248+
// Validate offset
2249+
if (totalMinutes < -24 * 60 || totalMinutes > 24 * 60) {
2250+
std::ostringstream oss;
2251+
oss << "Invalid timezone offset from SQL_SS_TIMESTAMPOFFSET_STRUCT: "
2252+
<< totalMinutes << " minutes for column " << i;
2253+
ThrowStdException(oss.str());
2254+
}
2255+
// Convert fraction from ns to µs
2256+
int microseconds = dtoValue.fraction / 1000;
2257+
py::object datetime = py::module_::import("datetime");
2258+
py::object tzinfo = datetime.attr("timezone")(
2259+
datetime.attr("timedelta")(py::arg("minutes") = totalMinutes)
2260+
);
2261+
py::object py_dt = datetime.attr("datetime")(
2262+
dtoValue.year,
2263+
dtoValue.month,
2264+
dtoValue.day,
2265+
dtoValue.hour,
2266+
dtoValue.minute,
2267+
dtoValue.second,
2268+
microseconds,
2269+
tzinfo
2270+
);
2271+
row.append(py_dt);
2272+
} else {
2273+
LOG("Error fetching DATETIMEOFFSET for column {}, ret={}", i, ret);
2274+
row.append(py::none());
2275+
}
2276+
break;
2277+
}
21832278
case SQL_BINARY:
21842279
case SQL_VARBINARY:
21852280
case SQL_LONGVARBINARY: {

0 commit comments

Comments
 (0)