Skip to content

Commit 5f45001

Browse files
committed
addressed review comments
1 parent f29f554 commit 5f45001

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);
@@ -1947,7 +1948,6 @@ SQLRETURN BindParameterArray(SQLHANDLE hStmt,
19471948
break;
19481949
}
19491950
case SQL_C_TYPE_TIMESTAMP: {
1950-
std::cout<<"Binding Timestamp param at index "<<paramIndex<<std::endl;
19511951
SQL_TIMESTAMP_STRUCT* tsArray = AllocateParamBufferArray<SQL_TIMESTAMP_STRUCT>(tempBuffers, paramSetSize);
19521952
strLenOrIndArray = AllocateParamBufferArray<SQLLEN>(tempBuffers, paramSetSize);
19531953
for (size_t i = 0; i < paramSetSize; ++i) {
@@ -1971,7 +1971,6 @@ SQLRETURN BindParameterArray(SQLHANDLE hStmt,
19711971
break;
19721972
}
19731973
case SQL_C_SS_TIMESTAMPOFFSET: {
1974-
std::cout<<"Binding DateTimeOffset param at index "<<paramIndex<<std::endl;
19751974
DateTimeOffset* dtoArray = AllocateParamBufferArray<DateTimeOffset>(tempBuffers, paramSetSize);
19761975
strLenOrIndArray = AllocateParamBufferArray<SQLLEN>(tempBuffers, paramSetSize);
19771976

@@ -1994,39 +1993,26 @@ SQLRETURN BindParameterArray(SQLHANDLE hStmt,
19941993
std::to_string(paramIndex));
19951994
}
19961995

1997-
// Convert the Python datetime object to UTC before binding.
1998-
// This is the crucial step to ensure timezone normalization.
1999-
py::object datetimeModule = py::module_::import("datetime");
2000-
py::object utc_dt = param.attr("astimezone")(datetimeModule.attr("timezone").attr("utc"));
2001-
std::cout<<"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"<<std::endl;
2002-
// --- TEMPORARY DEBUGGING: LOG THE UTC VALUES ---
2003-
LOG("Binding UTC values: {}-{}-{} {}:{}:{}.{} +00:00",
2004-
utc_dt.attr("year").cast<int>(),
2005-
utc_dt.attr("month").cast<int>(),
2006-
utc_dt.attr("day").cast<int>(),
2007-
utc_dt.attr("hour").cast<int>(),
2008-
utc_dt.attr("minute").cast<int>(),
2009-
utc_dt.attr("second").cast<int>(),
2010-
utc_dt.attr("microsecond").cast<int>()
2011-
);
2012-
2013-
// Now, populate the C++ struct using the UTC-converted object.
2014-
dtoArray[i].year = static_cast<SQLSMALLINT>(utc_dt.attr("year").cast<int>());
2015-
dtoArray[i].month = static_cast<SQLUSMALLINT>(utc_dt.attr("month").cast<int>());
2016-
dtoArray[i].day = static_cast<SQLUSMALLINT>(utc_dt.attr("day").cast<int>());
2017-
dtoArray[i].hour = static_cast<SQLUSMALLINT>(utc_dt.attr("hour").cast<int>());
2018-
dtoArray[i].minute = static_cast<SQLUSMALLINT>(utc_dt.attr("minute").cast<int>());
2019-
dtoArray[i].second = static_cast<SQLUSMALLINT>(utc_dt.attr("second").cast<int>());
2020-
dtoArray[i].fraction = static_cast<SQLUINTEGER>(utc_dt.attr("microsecond").cast<int>() * 1000);
2021-
2022-
// Since we've converted to UTC, the timezone offset is always 0.
2023-
dtoArray[i].timezone_hour = 0;
2024-
dtoArray[i].timezone_minute = 0;
1996+
// Populate the C++ struct directly from the Python datetime object.
1997+
dtoArray[i].year = static_cast<SQLSMALLINT>(param.attr("year").cast<int>());
1998+
dtoArray[i].month = static_cast<SQLUSMALLINT>(param.attr("month").cast<int>());
1999+
dtoArray[i].day = static_cast<SQLUSMALLINT>(param.attr("day").cast<int>());
2000+
dtoArray[i].hour = static_cast<SQLUSMALLINT>(param.attr("hour").cast<int>());
2001+
dtoArray[i].minute = static_cast<SQLUSMALLINT>(param.attr("minute").cast<int>());
2002+
dtoArray[i].second = static_cast<SQLUSMALLINT>(param.attr("second").cast<int>());
2003+
// SQL server supports in ns, but python datetime supports in µs
2004+
dtoArray[i].fraction = static_cast<SQLUINTEGER>(param.attr("microsecond").cast<int>() * 1000);
2005+
2006+
// Compute and preserve the original UTC offset.
2007+
py::object utcoffset = tzinfo.attr("utcoffset")(param);
2008+
int total_seconds = static_cast<int>(utcoffset.attr("total_seconds")().cast<double>());
2009+
std::div_t div_result = std::div(total_seconds, 3600);
2010+
dtoArray[i].timezone_hour = static_cast<SQLSMALLINT>(div_result.quot);
2011+
dtoArray[i].timezone_minute = static_cast<SQLSMALLINT>(div(div_result.rem, 60).quot);
20252012

20262013
strLenOrIndArray[i] = sizeof(DateTimeOffset);
20272014
}
20282015
}
2029-
20302016
dataPtr = dtoArray;
20312017
bufferLength = sizeof(DateTimeOffset);
20322018
break;

tests/test_004_cursor.py

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

7883+
def test_datetimeoffset_execute_vs_executemany_consistency(cursor, db_connection):
7884+
"""
7885+
Check that execute() and executemany() produce the same stored DATETIMEOFFSET
7886+
for identical timezone-aware datetime objects.
7887+
"""
7888+
try:
7889+
test_dt = datetime(2023, 10, 30, 12, 0, 0, microsecond=123456,
7890+
tzinfo=timezone(timedelta(hours=5, minutes=30)))
7891+
cursor.execute("IF OBJECT_ID('tempdb..#pytest_dto', 'U') IS NOT NULL DROP TABLE #pytest_dto;")
7892+
cursor.execute("CREATE TABLE #pytest_dto (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);")
7893+
db_connection.commit()
7894+
7895+
# Insert using execute()
7896+
cursor.execute("INSERT INTO #pytest_dto (id, dto_column) VALUES (?, ?);", 1, test_dt)
7897+
db_connection.commit()
7898+
7899+
# Insert using executemany()
7900+
cursor.executemany(
7901+
"INSERT INTO #pytest_dto (id, dto_column) VALUES (?, ?);",
7902+
[(2, test_dt)]
7903+
)
7904+
db_connection.commit()
7905+
7906+
cursor.execute("SELECT dto_column FROM #pytest_dto ORDER BY id;")
7907+
rows = cursor.fetchall()
7908+
assert len(rows) == 2
7909+
7910+
# Compare textual representation to ensure binding semantics match
7911+
cursor.execute("SELECT CONVERT(VARCHAR(35), dto_column, 127) FROM #pytest_dto ORDER BY id;")
7912+
textual_rows = [r[0] for r in cursor.fetchall()]
7913+
assert textual_rows[0] == textual_rows[1], "execute() and executemany() results differ"
7914+
7915+
finally:
7916+
cursor.execute("IF OBJECT_ID('tempdb..#pytest_dto', 'U') IS NOT NULL DROP TABLE #pytest_dto;")
7917+
db_connection.commit()
7918+
7919+
7920+
def test_datetimeoffset_extreme_offsets(cursor, db_connection):
7921+
"""
7922+
Test boundary offsets (+14:00 and -12:00) to ensure correct round-trip handling.
7923+
"""
7924+
try:
7925+
extreme_offsets = [
7926+
datetime(2023, 10, 30, 0, 0, 0, 0, tzinfo=timezone(timedelta(hours=14))),
7927+
datetime(2023, 10, 30, 0, 0, 0, 0, tzinfo=timezone(timedelta(hours=-12))),
7928+
]
7929+
7930+
cursor.execute("IF OBJECT_ID('tempdb..#pytest_dto', 'U') IS NOT NULL DROP TABLE #pytest_dto;")
7931+
cursor.execute("CREATE TABLE #pytest_dto (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);")
7932+
db_connection.commit()
7933+
7934+
param_list = [(i, dt) for i, dt in enumerate(extreme_offsets)]
7935+
cursor.executemany("INSERT INTO #pytest_dto (id, dto_column) VALUES (?, ?);", param_list)
7936+
db_connection.commit()
7937+
7938+
cursor.execute("SELECT id, dto_column FROM #pytest_dto ORDER BY id;")
7939+
rows = cursor.fetchall()
7940+
7941+
for i, dt in enumerate(extreme_offsets):
7942+
_, fetched = rows[i]
7943+
assert fetched.tzinfo is not None
7944+
# Round-trip comparison via UTC
7945+
expected_utc = dt.astimezone(timezone.utc).replace(tzinfo=None)
7946+
fetched_utc = fetched.astimezone(timezone.utc).replace(tzinfo=None)
7947+
assert expected_utc == fetched_utc, f"Extreme offset round-trip failed for {dt.tzinfo}"
7948+
finally:
7949+
cursor.execute("IF OBJECT_ID('tempdb..#pytest_dto', 'U') IS NOT NULL DROP TABLE #pytest_dto;")
7950+
db_connection.commit()
7951+
78837952
def test_lowercase_attribute(cursor, db_connection):
78847953
"""Test that the lowercase attribute properly converts column names to lowercase"""
78857954

0 commit comments

Comments
 (0)