Skip to content

Commit 6bbac3f

Browse files
committed
addressed review comments
1 parent 760d41f commit 6bbac3f

File tree

2 files changed

+86
-31
lines changed

2 files changed

+86
-31
lines changed

mssql_python/pybind/ddbc_bindings.cpp

Lines changed: 17 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,7 @@ SQLRETURN BindParameters(SQLHANDLE hStmt, const py::list& params,
498498
dtoPtr->hour = static_cast<SQLUSMALLINT>(param.attr("hour").cast<int>());
499499
dtoPtr->minute = static_cast<SQLUSMALLINT>(param.attr("minute").cast<int>());
500500
dtoPtr->second = static_cast<SQLUSMALLINT>(param.attr("second").cast<int>());
501+
// SQL server supports in ns, but python datetime supports in µs
501502
dtoPtr->fraction = static_cast<SQLUINTEGER>(param.attr("microsecond").cast<int>() * 1000);
502503

503504
py::object utcoffset = tzinfo.attr("utcoffset")(param);
@@ -1878,7 +1879,6 @@ SQLRETURN BindParameterArray(SQLHANDLE hStmt,
18781879
break;
18791880
}
18801881
case SQL_C_TYPE_TIMESTAMP: {
1881-
std::cout<<"Binding Timestamp param at index "<<paramIndex<<std::endl;
18821882
SQL_TIMESTAMP_STRUCT* tsArray = AllocateParamBufferArray<SQL_TIMESTAMP_STRUCT>(tempBuffers, paramSetSize);
18831883
strLenOrIndArray = AllocateParamBufferArray<SQLLEN>(tempBuffers, paramSetSize);
18841884
for (size_t i = 0; i < paramSetSize; ++i) {
@@ -1902,7 +1902,6 @@ SQLRETURN BindParameterArray(SQLHANDLE hStmt,
19021902
break;
19031903
}
19041904
case SQL_C_SS_TIMESTAMPOFFSET: {
1905-
std::cout<<"Binding DateTimeOffset param at index "<<paramIndex<<std::endl;
19061905
DateTimeOffset* dtoArray = AllocateParamBufferArray<DateTimeOffset>(tempBuffers, paramSetSize);
19071906
strLenOrIndArray = AllocateParamBufferArray<SQLLEN>(tempBuffers, paramSetSize);
19081907

@@ -1925,39 +1924,26 @@ SQLRETURN BindParameterArray(SQLHANDLE hStmt,
19251924
std::to_string(paramIndex));
19261925
}
19271926

1928-
// Convert the Python datetime object to UTC before binding.
1929-
// This is the crucial step to ensure timezone normalization.
1930-
py::object datetimeModule = py::module_::import("datetime");
1931-
py::object utc_dt = param.attr("astimezone")(datetimeModule.attr("timezone").attr("utc"));
1932-
std::cout<<"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"<<std::endl;
1933-
// --- TEMPORARY DEBUGGING: LOG THE UTC VALUES ---
1934-
LOG("Binding UTC values: {}-{}-{} {}:{}:{}.{} +00:00",
1935-
utc_dt.attr("year").cast<int>(),
1936-
utc_dt.attr("month").cast<int>(),
1937-
utc_dt.attr("day").cast<int>(),
1938-
utc_dt.attr("hour").cast<int>(),
1939-
utc_dt.attr("minute").cast<int>(),
1940-
utc_dt.attr("second").cast<int>(),
1941-
utc_dt.attr("microsecond").cast<int>()
1942-
);
1943-
1944-
// Now, populate the C++ struct using the UTC-converted object.
1945-
dtoArray[i].year = static_cast<SQLSMALLINT>(utc_dt.attr("year").cast<int>());
1946-
dtoArray[i].month = static_cast<SQLUSMALLINT>(utc_dt.attr("month").cast<int>());
1947-
dtoArray[i].day = static_cast<SQLUSMALLINT>(utc_dt.attr("day").cast<int>());
1948-
dtoArray[i].hour = static_cast<SQLUSMALLINT>(utc_dt.attr("hour").cast<int>());
1949-
dtoArray[i].minute = static_cast<SQLUSMALLINT>(utc_dt.attr("minute").cast<int>());
1950-
dtoArray[i].second = static_cast<SQLUSMALLINT>(utc_dt.attr("second").cast<int>());
1951-
dtoArray[i].fraction = static_cast<SQLUINTEGER>(utc_dt.attr("microsecond").cast<int>() * 1000);
1952-
1953-
// Since we've converted to UTC, the timezone offset is always 0.
1954-
dtoArray[i].timezone_hour = 0;
1955-
dtoArray[i].timezone_minute = 0;
1927+
// Populate the C++ struct directly from the Python datetime object.
1928+
dtoArray[i].year = static_cast<SQLSMALLINT>(param.attr("year").cast<int>());
1929+
dtoArray[i].month = static_cast<SQLUSMALLINT>(param.attr("month").cast<int>());
1930+
dtoArray[i].day = static_cast<SQLUSMALLINT>(param.attr("day").cast<int>());
1931+
dtoArray[i].hour = static_cast<SQLUSMALLINT>(param.attr("hour").cast<int>());
1932+
dtoArray[i].minute = static_cast<SQLUSMALLINT>(param.attr("minute").cast<int>());
1933+
dtoArray[i].second = static_cast<SQLUSMALLINT>(param.attr("second").cast<int>());
1934+
// SQL server supports in ns, but python datetime supports in µs
1935+
dtoArray[i].fraction = static_cast<SQLUINTEGER>(param.attr("microsecond").cast<int>() * 1000);
1936+
1937+
// Compute and preserve the original UTC offset.
1938+
py::object utcoffset = tzinfo.attr("utcoffset")(param);
1939+
int total_seconds = static_cast<int>(utcoffset.attr("total_seconds")().cast<double>());
1940+
std::div_t div_result = std::div(total_seconds, 3600);
1941+
dtoArray[i].timezone_hour = static_cast<SQLSMALLINT>(div_result.quot);
1942+
dtoArray[i].timezone_minute = static_cast<SQLSMALLINT>(div(div_result.rem, 60).quot);
19561943

19571944
strLenOrIndArray[i] = sizeof(DateTimeOffset);
19581945
}
19591946
}
1960-
19611947
dataPtr = dtoArray;
19621948
bufferLength = sizeof(DateTimeOffset);
19631949
break;

tests/test_004_cursor.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7472,6 +7472,75 @@ def test_datetimeoffset_executemany(cursor, db_connection):
74727472
cursor.execute("IF OBJECT_ID('tempdb..#pytest_dto', 'U') IS NOT NULL DROP TABLE #pytest_dto;")
74737473
db_connection.commit()
74747474

7475+
def test_datetimeoffset_execute_vs_executemany_consistency(cursor, db_connection):
7476+
"""
7477+
Check that execute() and executemany() produce the same stored DATETIMEOFFSET
7478+
for identical timezone-aware datetime objects.
7479+
"""
7480+
try:
7481+
test_dt = datetime(2023, 10, 30, 12, 0, 0, microsecond=123456,
7482+
tzinfo=timezone(timedelta(hours=5, minutes=30)))
7483+
cursor.execute("IF OBJECT_ID('tempdb..#pytest_dto', 'U') IS NOT NULL DROP TABLE #pytest_dto;")
7484+
cursor.execute("CREATE TABLE #pytest_dto (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);")
7485+
db_connection.commit()
7486+
7487+
# Insert using execute()
7488+
cursor.execute("INSERT INTO #pytest_dto (id, dto_column) VALUES (?, ?);", 1, test_dt)
7489+
db_connection.commit()
7490+
7491+
# Insert using executemany()
7492+
cursor.executemany(
7493+
"INSERT INTO #pytest_dto (id, dto_column) VALUES (?, ?);",
7494+
[(2, test_dt)]
7495+
)
7496+
db_connection.commit()
7497+
7498+
cursor.execute("SELECT dto_column FROM #pytest_dto ORDER BY id;")
7499+
rows = cursor.fetchall()
7500+
assert len(rows) == 2
7501+
7502+
# Compare textual representation to ensure binding semantics match
7503+
cursor.execute("SELECT CONVERT(VARCHAR(35), dto_column, 127) FROM #pytest_dto ORDER BY id;")
7504+
textual_rows = [r[0] for r in cursor.fetchall()]
7505+
assert textual_rows[0] == textual_rows[1], "execute() and executemany() results differ"
7506+
7507+
finally:
7508+
cursor.execute("IF OBJECT_ID('tempdb..#pytest_dto', 'U') IS NOT NULL DROP TABLE #pytest_dto;")
7509+
db_connection.commit()
7510+
7511+
7512+
def test_datetimeoffset_extreme_offsets(cursor, db_connection):
7513+
"""
7514+
Test boundary offsets (+14:00 and -12:00) to ensure correct round-trip handling.
7515+
"""
7516+
try:
7517+
extreme_offsets = [
7518+
datetime(2023, 10, 30, 0, 0, 0, 0, tzinfo=timezone(timedelta(hours=14))),
7519+
datetime(2023, 10, 30, 0, 0, 0, 0, tzinfo=timezone(timedelta(hours=-12))),
7520+
]
7521+
7522+
cursor.execute("IF OBJECT_ID('tempdb..#pytest_dto', 'U') IS NOT NULL DROP TABLE #pytest_dto;")
7523+
cursor.execute("CREATE TABLE #pytest_dto (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);")
7524+
db_connection.commit()
7525+
7526+
param_list = [(i, dt) for i, dt in enumerate(extreme_offsets)]
7527+
cursor.executemany("INSERT INTO #pytest_dto (id, dto_column) VALUES (?, ?);", param_list)
7528+
db_connection.commit()
7529+
7530+
cursor.execute("SELECT id, dto_column FROM #pytest_dto ORDER BY id;")
7531+
rows = cursor.fetchall()
7532+
7533+
for i, dt in enumerate(extreme_offsets):
7534+
_, fetched = rows[i]
7535+
assert fetched.tzinfo is not None
7536+
# Round-trip comparison via UTC
7537+
expected_utc = dt.astimezone(timezone.utc).replace(tzinfo=None)
7538+
fetched_utc = fetched.astimezone(timezone.utc).replace(tzinfo=None)
7539+
assert expected_utc == fetched_utc, f"Extreme offset round-trip failed for {dt.tzinfo}"
7540+
finally:
7541+
cursor.execute("IF OBJECT_ID('tempdb..#pytest_dto', 'U') IS NOT NULL DROP TABLE #pytest_dto;")
7542+
db_connection.commit()
7543+
74757544
def test_lowercase_attribute(cursor, db_connection):
74767545
"""Test that the lowercase attribute properly converts column names to lowercase"""
74777546

0 commit comments

Comments
 (0)