Skip to content

Commit d63d26c

Browse files
authored
Merge branch 'main' into saumya/api-bug
2 parents cadb9cc + 7b91e8f commit d63d26c

5 files changed

Lines changed: 230 additions & 19 deletions

File tree

mssql_python/cursor.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ def _parse_date(self, param):
137137
except ValueError:
138138
continue
139139
return None
140-
140+
141141
def _parse_datetime(self, param):
142142
"""
143143
Attempt to parse a string as a datetime, smalldatetime, datetime2, timestamp.
@@ -1613,7 +1613,7 @@ def executemany(self, operation: str, seq_of_parameters: list) -> None:
16131613
# Use auto-detection for columns without explicit types
16141614
column = [row[col_index] for row in seq_of_parameters] if hasattr(seq_of_parameters, '__getitem__') else []
16151615
sample_value, min_val, max_val = self._compute_column_type(column)
1616-
1616+
16171617
dummy_row = list(sample_row)
16181618
paraminfo = self._create_parameter_types_list(
16191619
sample_value, param_info, dummy_row, col_index, min_val=min_val, max_val=max_val

mssql_python/msvcp140.dll

-562 KB
Binary file not shown.

mssql_python/pybind/ddbc_bindings.cpp

Lines changed: 102 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,20 @@ struct NumericData {
6464
: precision(precision), scale(scale), sign(sign), val(value) {}
6565
};
6666

67+
// Struct to hold the DateTimeOffset structure
68+
struct DateTimeOffset
69+
{
70+
SQLSMALLINT year;
71+
SQLUSMALLINT month;
72+
SQLUSMALLINT day;
73+
SQLUSMALLINT hour;
74+
SQLUSMALLINT minute;
75+
SQLUSMALLINT second;
76+
SQLUINTEGER fraction; // Nanoseconds
77+
SQLSMALLINT timezone_hour; // Offset hours from UTC
78+
SQLSMALLINT timezone_minute; // Offset minutes from UTC
79+
};
80+
6781
// Struct to hold data buffers and indicators for each column
6882
struct ColumnBuffers {
6983
std::vector<std::vector<SQLCHAR>> charBuffers;
@@ -78,6 +92,7 @@ struct ColumnBuffers {
7892
std::vector<std::vector<SQL_TIME_STRUCT>> timeBuffers;
7993
std::vector<std::vector<SQLGUID>> guidBuffers;
8094
std::vector<std::vector<SQLLEN>> indicators;
95+
std::vector<std::vector<DateTimeOffset>> datetimeoffsetBuffers;
8196

8297
ColumnBuffers(SQLSMALLINT numCols, int fetchSize)
8398
: charBuffers(numCols),
@@ -91,23 +106,10 @@ struct ColumnBuffers {
91106
dateBuffers(numCols),
92107
timeBuffers(numCols),
93108
guidBuffers(numCols),
109+
datetimeoffsetBuffers(numCols),
94110
indicators(numCols, std::vector<SQLLEN>(fetchSize)) {}
95111
};
96112

97-
// Struct to hold the DateTimeOffset structure
98-
struct DateTimeOffset
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-
111113
//-------------------------------------------------------------------------------------------------
112114
// Function pointer initialization
113115
//-------------------------------------------------------------------------------------------------
@@ -496,6 +498,7 @@ SQLRETURN BindParameters(SQLHANDLE hStmt, const py::list& params,
496498
dtoPtr->hour = static_cast<SQLUSMALLINT>(param.attr("hour").cast<int>());
497499
dtoPtr->minute = static_cast<SQLUSMALLINT>(param.attr("minute").cast<int>());
498500
dtoPtr->second = static_cast<SQLUSMALLINT>(param.attr("second").cast<int>());
501+
// SQL server supports in ns, but python datetime supports in µs
499502
dtoPtr->fraction = static_cast<SQLUINTEGER>(param.attr("microsecond").cast<int>() * 1000);
500503

501504
py::object utcoffset = tzinfo.attr("utcoffset")(param);
@@ -1934,6 +1937,53 @@ SQLRETURN BindParameterArray(SQLHANDLE hStmt,
19341937
bufferLength = sizeof(SQL_TIMESTAMP_STRUCT);
19351938
break;
19361939
}
1940+
case SQL_C_SS_TIMESTAMPOFFSET: {
1941+
DateTimeOffset* dtoArray = AllocateParamBufferArray<DateTimeOffset>(tempBuffers, paramSetSize);
1942+
strLenOrIndArray = AllocateParamBufferArray<SQLLEN>(tempBuffers, paramSetSize);
1943+
1944+
py::object datetimeType = py::module_::import("datetime").attr("datetime");
1945+
1946+
for (size_t i = 0; i < paramSetSize; ++i) {
1947+
const py::handle& param = columnValues[i];
1948+
1949+
if (param.is_none()) {
1950+
std::memset(&dtoArray[i], 0, sizeof(DateTimeOffset));
1951+
strLenOrIndArray[i] = SQL_NULL_DATA;
1952+
} else {
1953+
if (!py::isinstance(param, datetimeType)) {
1954+
ThrowStdException(MakeParamMismatchErrorStr(info.paramCType, paramIndex));
1955+
}
1956+
1957+
py::object tzinfo = param.attr("tzinfo");
1958+
if (tzinfo.is_none()) {
1959+
ThrowStdException("Datetime object must have tzinfo for SQL_C_SS_TIMESTAMPOFFSET at paramIndex " +
1960+
std::to_string(paramIndex));
1961+
}
1962+
1963+
// Populate the C++ struct directly from the Python datetime object.
1964+
dtoArray[i].year = static_cast<SQLSMALLINT>(param.attr("year").cast<int>());
1965+
dtoArray[i].month = static_cast<SQLUSMALLINT>(param.attr("month").cast<int>());
1966+
dtoArray[i].day = static_cast<SQLUSMALLINT>(param.attr("day").cast<int>());
1967+
dtoArray[i].hour = static_cast<SQLUSMALLINT>(param.attr("hour").cast<int>());
1968+
dtoArray[i].minute = static_cast<SQLUSMALLINT>(param.attr("minute").cast<int>());
1969+
dtoArray[i].second = static_cast<SQLUSMALLINT>(param.attr("second").cast<int>());
1970+
// SQL server supports in ns, but python datetime supports in µs
1971+
dtoArray[i].fraction = static_cast<SQLUINTEGER>(param.attr("microsecond").cast<int>() * 1000);
1972+
1973+
// Compute and preserve the original UTC offset.
1974+
py::object utcoffset = tzinfo.attr("utcoffset")(param);
1975+
int total_seconds = static_cast<int>(utcoffset.attr("total_seconds")().cast<double>());
1976+
std::div_t div_result = std::div(total_seconds, 3600);
1977+
dtoArray[i].timezone_hour = static_cast<SQLSMALLINT>(div_result.quot);
1978+
dtoArray[i].timezone_minute = static_cast<SQLSMALLINT>(div(div_result.rem, 60).quot);
1979+
1980+
strLenOrIndArray[i] = sizeof(DateTimeOffset);
1981+
}
1982+
}
1983+
dataPtr = dtoArray;
1984+
bufferLength = sizeof(DateTimeOffset);
1985+
break;
1986+
}
19371987
case SQL_C_NUMERIC: {
19381988
SQL_NUMERIC_STRUCT* numericArray = AllocateParamBufferArray<SQL_NUMERIC_STRUCT>(tempBuffers, paramSetSize);
19391989
strLenOrIndArray = AllocateParamBufferArray<SQLLEN>(tempBuffers, paramSetSize);
@@ -2658,6 +2708,7 @@ SQLRETURN SQLGetData_wrap(SqlHandlePtr StatementHandle, SQLUSMALLINT colCount, p
26582708
microseconds,
26592709
tzinfo
26602710
);
2711+
py_dt = py_dt.attr("astimezone")(datetime.attr("timezone").attr("utc"));
26612712
row.append(py_dt);
26622713
} else {
26632714
LOG("Error fetching DATETIMEOFFSET for column {}, ret={}", i, ret);
@@ -2928,6 +2979,13 @@ SQLRETURN SQLBindColums(SQLHSTMT hStmt, ColumnBuffers& buffers, py::list& column
29282979
ret = SQLBindCol_ptr(hStmt, col, SQL_C_BINARY, buffers.charBuffers[col - 1].data(),
29292980
columnSize, buffers.indicators[col - 1].data());
29302981
break;
2982+
case SQL_SS_TIMESTAMPOFFSET:
2983+
buffers.datetimeoffsetBuffers[col - 1].resize(fetchSize);
2984+
ret = SQLBindCol_ptr(hStmt, col, SQL_C_SS_TIMESTAMPOFFSET,
2985+
buffers.datetimeoffsetBuffers[col - 1].data(),
2986+
sizeof(DateTimeOffset) * fetchSize,
2987+
buffers.indicators[col - 1].data());
2988+
break;
29312989
default:
29322990
std::wstring columnName = columnMeta["ColumnName"].cast<std::wstring>();
29332991
std::ostringstream errorString;
@@ -3143,6 +3201,33 @@ SQLRETURN FetchBatchData(SQLHSTMT hStmt, ColumnBuffers& buffers, py::list& colum
31433201
buffers.timeBuffers[col - 1][i].second));
31443202
break;
31453203
}
3204+
case SQL_SS_TIMESTAMPOFFSET: {
3205+
SQLULEN rowIdx = i;
3206+
const DateTimeOffset& dtoValue = buffers.datetimeoffsetBuffers[col - 1][rowIdx];
3207+
SQLLEN indicator = buffers.indicators[col - 1][rowIdx];
3208+
if (indicator != SQL_NULL_DATA) {
3209+
int totalMinutes = dtoValue.timezone_hour * 60 + dtoValue.timezone_minute;
3210+
py::object datetime = py::module_::import("datetime");
3211+
py::object tzinfo = datetime.attr("timezone")(
3212+
datetime.attr("timedelta")(py::arg("minutes") = totalMinutes)
3213+
);
3214+
py::object py_dt = datetime.attr("datetime")(
3215+
dtoValue.year,
3216+
dtoValue.month,
3217+
dtoValue.day,
3218+
dtoValue.hour,
3219+
dtoValue.minute,
3220+
dtoValue.second,
3221+
dtoValue.fraction / 1000, // ns → µs
3222+
tzinfo
3223+
);
3224+
py_dt = py_dt.attr("astimezone")(datetime.attr("timezone").attr("utc"));
3225+
row.append(py_dt);
3226+
} else {
3227+
row.append(py::none());
3228+
}
3229+
break;
3230+
}
31463231
case SQL_GUID: {
31473232
SQLGUID* guidValue = &buffers.guidBuffers[col - 1][i];
31483233
uint8_t reordered[16];
@@ -3262,6 +3347,9 @@ size_t calculateRowSize(py::list& columnNames, SQLUSMALLINT numCols) {
32623347
case SQL_LONGVARBINARY:
32633348
rowSize += columnSize;
32643349
break;
3350+
case SQL_SS_TIMESTAMPOFFSET:
3351+
rowSize += sizeof(DateTimeOffset);
3352+
break;
32653353
default:
32663354
std::wstring columnName = columnMeta["ColumnName"].cast<std::wstring>();
32673355
std::ostringstream errorString;

tests/test_003_connection.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3099,7 +3099,6 @@ def test_execute_with_large_parameters(db_connection):
30993099
- Working with parameters near but under the size limit
31003100
- Processing large result sets
31013101
"""
3102-
import time
31033102

31043103
# Test with a temporary table for large data
31053104
cursor = db_connection.execute("""
@@ -4114,8 +4113,6 @@ def test_timeout_from_constructor(conn_str):
41144113

41154114
def test_timeout_long_query(db_connection):
41164115
"""Test that a query exceeding the timeout raises an exception if supported by driver"""
4117-
import time
4118-
import pytest
41194116

41204117
cursor = db_connection.cursor()
41214118

tests/test_004_cursor.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7934,6 +7934,132 @@ def test_datetimeoffset_malformed_input(cursor, db_connection):
79347934
finally:
79357935
cursor.execute("DROP TABLE IF EXISTS #pytest_datetimeoffset_malformed_input;")
79367936
db_connection.commit()
7937+
7938+
def test_datetimeoffset_executemany(cursor, db_connection):
7939+
"""
7940+
Test the driver's ability to correctly read and write DATETIMEOFFSET data
7941+
using executemany, including timezone information.
7942+
"""
7943+
try:
7944+
datetimeoffset_test_cases = [
7945+
(
7946+
"2023-10-26 10:30:00.0000000 +05:30",
7947+
datetime(2023, 10, 26, 10, 30, 0, 0,
7948+
tzinfo=timezone(timedelta(hours=5, minutes=30)))
7949+
),
7950+
(
7951+
"2023-10-27 15:45:10.1234567 -08:00",
7952+
datetime(2023, 10, 27, 15, 45, 10, 123456,
7953+
tzinfo=timezone(timedelta(hours=-8)))
7954+
),
7955+
(
7956+
"2023-10-28 20:00:05.9876543 +00:00",
7957+
datetime(2023, 10, 28, 20, 0, 5, 987654,
7958+
tzinfo=timezone(timedelta(hours=0)))
7959+
)
7960+
]
7961+
7962+
# Create temp table
7963+
cursor.execute("IF OBJECT_ID('tempdb..#pytest_dto', 'U') IS NOT NULL DROP TABLE #pytest_dto;")
7964+
cursor.execute("CREATE TABLE #pytest_dto (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);")
7965+
db_connection.commit()
7966+
7967+
# Prepare data for executemany
7968+
param_list = [(i, python_dt) for i, (_, python_dt) in enumerate(datetimeoffset_test_cases)]
7969+
cursor.executemany("INSERT INTO #pytest_dto (id, dto_column) VALUES (?, ?);", param_list)
7970+
db_connection.commit()
7971+
7972+
# Read back and validate
7973+
cursor.execute("SELECT id, dto_column FROM #pytest_dto ORDER BY id;")
7974+
rows = cursor.fetchall()
7975+
7976+
for i, (sql_str, python_dt) in enumerate(datetimeoffset_test_cases):
7977+
fetched_id, fetched_dto = rows[i]
7978+
assert fetched_dto.tzinfo is not None, "Fetched datetime object is naive."
7979+
7980+
expected_utc = python_dt.astimezone(timezone.utc).replace(tzinfo=None)
7981+
fetched_utc = fetched_dto.astimezone(timezone.utc).replace(tzinfo=None)
7982+
7983+
# Round microseconds to nearest millisecond for comparison
7984+
expected_utc = expected_utc.replace(microsecond=int(expected_utc.microsecond / 1000) * 1000)
7985+
fetched_utc = fetched_utc.replace(microsecond=int(fetched_utc.microsecond / 1000) * 1000)
7986+
7987+
assert fetched_utc == expected_utc, (
7988+
f"Value mismatch for test case {i}. "
7989+
f"Expected UTC: {expected_utc}, Got UTC: {fetched_utc}"
7990+
)
7991+
finally:
7992+
cursor.execute("IF OBJECT_ID('tempdb..#pytest_dto', 'U') IS NOT NULL DROP TABLE #pytest_dto;")
7993+
db_connection.commit()
7994+
7995+
def test_datetimeoffset_execute_vs_executemany_consistency(cursor, db_connection):
7996+
"""
7997+
Check that execute() and executemany() produce the same stored DATETIMEOFFSET
7998+
for identical timezone-aware datetime objects.
7999+
"""
8000+
try:
8001+
test_dt = datetime(2023, 10, 30, 12, 0, 0, microsecond=123456,
8002+
tzinfo=timezone(timedelta(hours=5, minutes=30)))
8003+
cursor.execute("IF OBJECT_ID('tempdb..#pytest_dto', 'U') IS NOT NULL DROP TABLE #pytest_dto;")
8004+
cursor.execute("CREATE TABLE #pytest_dto (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);")
8005+
db_connection.commit()
8006+
8007+
# Insert using execute()
8008+
cursor.execute("INSERT INTO #pytest_dto (id, dto_column) VALUES (?, ?);", 1, test_dt)
8009+
db_connection.commit()
8010+
8011+
# Insert using executemany()
8012+
cursor.executemany(
8013+
"INSERT INTO #pytest_dto (id, dto_column) VALUES (?, ?);",
8014+
[(2, test_dt)]
8015+
)
8016+
db_connection.commit()
8017+
8018+
cursor.execute("SELECT dto_column FROM #pytest_dto ORDER BY id;")
8019+
rows = cursor.fetchall()
8020+
assert len(rows) == 2
8021+
8022+
# Compare textual representation to ensure binding semantics match
8023+
cursor.execute("SELECT CONVERT(VARCHAR(35), dto_column, 127) FROM #pytest_dto ORDER BY id;")
8024+
textual_rows = [r[0] for r in cursor.fetchall()]
8025+
assert textual_rows[0] == textual_rows[1], "execute() and executemany() results differ"
8026+
8027+
finally:
8028+
cursor.execute("IF OBJECT_ID('tempdb..#pytest_dto', 'U') IS NOT NULL DROP TABLE #pytest_dto;")
8029+
db_connection.commit()
8030+
8031+
8032+
def test_datetimeoffset_extreme_offsets(cursor, db_connection):
8033+
"""
8034+
Test boundary offsets (+14:00 and -12:00) to ensure correct round-trip handling.
8035+
"""
8036+
try:
8037+
extreme_offsets = [
8038+
datetime(2023, 10, 30, 0, 0, 0, 0, tzinfo=timezone(timedelta(hours=14))),
8039+
datetime(2023, 10, 30, 0, 0, 0, 0, tzinfo=timezone(timedelta(hours=-12))),
8040+
]
8041+
8042+
cursor.execute("IF OBJECT_ID('tempdb..#pytest_dto', 'U') IS NOT NULL DROP TABLE #pytest_dto;")
8043+
cursor.execute("CREATE TABLE #pytest_dto (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);")
8044+
db_connection.commit()
8045+
8046+
param_list = [(i, dt) for i, dt in enumerate(extreme_offsets)]
8047+
cursor.executemany("INSERT INTO #pytest_dto (id, dto_column) VALUES (?, ?);", param_list)
8048+
db_connection.commit()
8049+
8050+
cursor.execute("SELECT id, dto_column FROM #pytest_dto ORDER BY id;")
8051+
rows = cursor.fetchall()
8052+
8053+
for i, dt in enumerate(extreme_offsets):
8054+
_, fetched = rows[i]
8055+
assert fetched.tzinfo is not None
8056+
# Round-trip comparison via UTC
8057+
expected_utc = dt.astimezone(timezone.utc).replace(tzinfo=None)
8058+
fetched_utc = fetched.astimezone(timezone.utc).replace(tzinfo=None)
8059+
assert expected_utc == fetched_utc, f"Extreme offset round-trip failed for {dt.tzinfo}"
8060+
finally:
8061+
cursor.execute("IF OBJECT_ID('tempdb..#pytest_dto', 'U') IS NOT NULL DROP TABLE #pytest_dto;")
8062+
db_connection.commit()
79378063

79388064
def test_lowercase_attribute(cursor, db_connection):
79398065
"""Test that the lowercase attribute properly converts column names to lowercase"""

0 commit comments

Comments
 (0)