diff --git a/mssql_python/cursor.py b/mssql_python/cursor.py index 30541f45..e693222e 100644 --- a/mssql_python/cursor.py +++ b/mssql_python/cursor.py @@ -137,7 +137,7 @@ def _parse_date(self, param): except ValueError: continue return None - + def _parse_datetime(self, param): """ Attempt to parse a string as a datetime, smalldatetime, datetime2, timestamp. @@ -1442,35 +1442,6 @@ def columns(self, table=None, catalog=None, schema=None, column=None): # Use the helper method to prepare the result set return self._prepare_metadata_result_set(fallback_description=fallback_description) - @staticmethod - def _select_best_sample_value(column): - """ - Selects the most representative non-null value from a column for type inference. - - This is used during executemany() to infer SQL/C types based on actual data, - preferring a non-null value that is not the first row to avoid bias from placeholder defaults. - - Args: - column: List of values in the column. - """ - non_nulls = [v for v in column if v is not None] - if not non_nulls: - return None - if all(isinstance(v, int) for v in non_nulls): - # Pick the value with the widest range (min/max) - return max(non_nulls, key=lambda v: abs(v)) - if all(isinstance(v, float) for v in non_nulls): - return 0.0 - if all(isinstance(v, decimal.Decimal) for v in non_nulls): - return max(non_nulls, key=lambda d: len(d.as_tuple().digits)) - if all(isinstance(v, str) for v in non_nulls): - return max(non_nulls, key=lambda s: len(str(s))) - if all(isinstance(v, datetime.datetime) for v in non_nulls): - return datetime.datetime.now() - if all(isinstance(v, datetime.date) for v in non_nulls): - return datetime.date.today() - return non_nulls[0] # fallback - def _transpose_rowwise_to_columnwise(self, seq_of_parameters: list) -> tuple[list, int]: """ Convert sequence of rows (row-wise) into list of columns (column-wise), @@ -1641,12 +1612,7 @@ def executemany(self, operation: str, seq_of_parameters: list) -> None: else: # Use auto-detection for columns without explicit types column = [row[col_index] for row in seq_of_parameters] if hasattr(seq_of_parameters, '__getitem__') else [] - if not column: - # For generators, use the sample row for inference - sample_value = sample_row[col_index] - else: - sample_value = self._select_best_sample_value(column) - + sample_value, min_val, max_val = self._compute_column_type(column) dummy_row = list(sample_row) paraminfo = self._create_parameter_types_list( sample_value, param_info, dummy_row, col_index, min_val=min_val, max_val=max_val diff --git a/mssql_python/msvcp140.dll b/mssql_python/msvcp140.dll deleted file mode 100644 index 0a9b13d7..00000000 Binary files a/mssql_python/msvcp140.dll and /dev/null differ diff --git a/mssql_python/pybind/ddbc_bindings.cpp b/mssql_python/pybind/ddbc_bindings.cpp index 744383e7..72951246 100644 --- a/mssql_python/pybind/ddbc_bindings.cpp +++ b/mssql_python/pybind/ddbc_bindings.cpp @@ -64,6 +64,20 @@ struct NumericData { : precision(precision), scale(scale), sign(sign), val(value) {} }; +// Struct to hold the DateTimeOffset structure +struct DateTimeOffset +{ + SQLSMALLINT year; + SQLUSMALLINT month; + SQLUSMALLINT day; + SQLUSMALLINT hour; + SQLUSMALLINT minute; + SQLUSMALLINT second; + SQLUINTEGER fraction; // Nanoseconds + SQLSMALLINT timezone_hour; // Offset hours from UTC + SQLSMALLINT timezone_minute; // Offset minutes from UTC +}; + // Struct to hold data buffers and indicators for each column struct ColumnBuffers { std::vector> charBuffers; @@ -78,6 +92,7 @@ struct ColumnBuffers { std::vector> timeBuffers; std::vector> guidBuffers; std::vector> indicators; + std::vector> datetimeoffsetBuffers; ColumnBuffers(SQLSMALLINT numCols, int fetchSize) : charBuffers(numCols), @@ -91,23 +106,10 @@ struct ColumnBuffers { dateBuffers(numCols), timeBuffers(numCols), guidBuffers(numCols), + datetimeoffsetBuffers(numCols), indicators(numCols, std::vector(fetchSize)) {} }; -// Struct to hold the DateTimeOffset structure -struct DateTimeOffset -{ - SQLSMALLINT year; - SQLUSMALLINT month; - SQLUSMALLINT day; - SQLUSMALLINT hour; - SQLUSMALLINT minute; - SQLUSMALLINT second; - SQLUINTEGER fraction; // Nanoseconds - SQLSMALLINT timezone_hour; // Offset hours from UTC - SQLSMALLINT timezone_minute; // Offset minutes from UTC -}; - //------------------------------------------------------------------------------------------------- // Function pointer initialization //------------------------------------------------------------------------------------------------- @@ -496,6 +498,7 @@ SQLRETURN BindParameters(SQLHANDLE hStmt, const py::list& params, dtoPtr->hour = static_cast(param.attr("hour").cast()); dtoPtr->minute = static_cast(param.attr("minute").cast()); dtoPtr->second = static_cast(param.attr("second").cast()); + // SQL server supports in ns, but python datetime supports in µs dtoPtr->fraction = static_cast(param.attr("microsecond").cast() * 1000); py::object utcoffset = tzinfo.attr("utcoffset")(param); @@ -1934,6 +1937,53 @@ SQLRETURN BindParameterArray(SQLHANDLE hStmt, bufferLength = sizeof(SQL_TIMESTAMP_STRUCT); break; } + case SQL_C_SS_TIMESTAMPOFFSET: { + DateTimeOffset* dtoArray = AllocateParamBufferArray(tempBuffers, paramSetSize); + strLenOrIndArray = AllocateParamBufferArray(tempBuffers, paramSetSize); + + py::object datetimeType = py::module_::import("datetime").attr("datetime"); + + for (size_t i = 0; i < paramSetSize; ++i) { + const py::handle& param = columnValues[i]; + + if (param.is_none()) { + std::memset(&dtoArray[i], 0, sizeof(DateTimeOffset)); + strLenOrIndArray[i] = SQL_NULL_DATA; + } else { + if (!py::isinstance(param, datetimeType)) { + ThrowStdException(MakeParamMismatchErrorStr(info.paramCType, paramIndex)); + } + + py::object tzinfo = param.attr("tzinfo"); + if (tzinfo.is_none()) { + ThrowStdException("Datetime object must have tzinfo for SQL_C_SS_TIMESTAMPOFFSET at paramIndex " + + std::to_string(paramIndex)); + } + + // Populate the C++ struct directly from the Python datetime object. + dtoArray[i].year = static_cast(param.attr("year").cast()); + dtoArray[i].month = static_cast(param.attr("month").cast()); + dtoArray[i].day = static_cast(param.attr("day").cast()); + dtoArray[i].hour = static_cast(param.attr("hour").cast()); + dtoArray[i].minute = static_cast(param.attr("minute").cast()); + dtoArray[i].second = static_cast(param.attr("second").cast()); + // SQL server supports in ns, but python datetime supports in µs + dtoArray[i].fraction = static_cast(param.attr("microsecond").cast() * 1000); + + // Compute and preserve the original UTC offset. + py::object utcoffset = tzinfo.attr("utcoffset")(param); + int total_seconds = static_cast(utcoffset.attr("total_seconds")().cast()); + std::div_t div_result = std::div(total_seconds, 3600); + dtoArray[i].timezone_hour = static_cast(div_result.quot); + dtoArray[i].timezone_minute = static_cast(div(div_result.rem, 60).quot); + + strLenOrIndArray[i] = sizeof(DateTimeOffset); + } + } + dataPtr = dtoArray; + bufferLength = sizeof(DateTimeOffset); + break; + } case SQL_C_NUMERIC: { SQL_NUMERIC_STRUCT* numericArray = AllocateParamBufferArray(tempBuffers, paramSetSize); strLenOrIndArray = AllocateParamBufferArray(tempBuffers, paramSetSize); @@ -2658,6 +2708,7 @@ SQLRETURN SQLGetData_wrap(SqlHandlePtr StatementHandle, SQLUSMALLINT colCount, p microseconds, tzinfo ); + py_dt = py_dt.attr("astimezone")(datetime.attr("timezone").attr("utc")); row.append(py_dt); } else { LOG("Error fetching DATETIMEOFFSET for column {}, ret={}", i, ret); @@ -2928,6 +2979,13 @@ SQLRETURN SQLBindColums(SQLHSTMT hStmt, ColumnBuffers& buffers, py::list& column ret = SQLBindCol_ptr(hStmt, col, SQL_C_BINARY, buffers.charBuffers[col - 1].data(), columnSize, buffers.indicators[col - 1].data()); break; + case SQL_SS_TIMESTAMPOFFSET: + buffers.datetimeoffsetBuffers[col - 1].resize(fetchSize); + ret = SQLBindCol_ptr(hStmt, col, SQL_C_SS_TIMESTAMPOFFSET, + buffers.datetimeoffsetBuffers[col - 1].data(), + sizeof(DateTimeOffset) * fetchSize, + buffers.indicators[col - 1].data()); + break; default: std::wstring columnName = columnMeta["ColumnName"].cast(); std::ostringstream errorString; @@ -3143,6 +3201,33 @@ SQLRETURN FetchBatchData(SQLHSTMT hStmt, ColumnBuffers& buffers, py::list& colum buffers.timeBuffers[col - 1][i].second)); break; } + case SQL_SS_TIMESTAMPOFFSET: { + SQLULEN rowIdx = i; + const DateTimeOffset& dtoValue = buffers.datetimeoffsetBuffers[col - 1][rowIdx]; + SQLLEN indicator = buffers.indicators[col - 1][rowIdx]; + if (indicator != SQL_NULL_DATA) { + int totalMinutes = dtoValue.timezone_hour * 60 + dtoValue.timezone_minute; + py::object datetime = py::module_::import("datetime"); + py::object tzinfo = datetime.attr("timezone")( + datetime.attr("timedelta")(py::arg("minutes") = totalMinutes) + ); + py::object py_dt = datetime.attr("datetime")( + dtoValue.year, + dtoValue.month, + dtoValue.day, + dtoValue.hour, + dtoValue.minute, + dtoValue.second, + dtoValue.fraction / 1000, // ns → µs + tzinfo + ); + py_dt = py_dt.attr("astimezone")(datetime.attr("timezone").attr("utc")); + row.append(py_dt); + } else { + row.append(py::none()); + } + break; + } case SQL_GUID: { SQLGUID* guidValue = &buffers.guidBuffers[col - 1][i]; uint8_t reordered[16]; @@ -3262,6 +3347,9 @@ size_t calculateRowSize(py::list& columnNames, SQLUSMALLINT numCols) { case SQL_LONGVARBINARY: rowSize += columnSize; break; + case SQL_SS_TIMESTAMPOFFSET: + rowSize += sizeof(DateTimeOffset); + break; default: std::wstring columnName = columnMeta["ColumnName"].cast(); std::ostringstream errorString; diff --git a/tests/test_003_connection.py b/tests/test_003_connection.py index f855ed4a..3512fc8e 100644 --- a/tests/test_003_connection.py +++ b/tests/test_003_connection.py @@ -3099,7 +3099,6 @@ def test_execute_with_large_parameters(db_connection): - Working with parameters near but under the size limit - Processing large result sets """ - import time # Test with a temporary table for large data cursor = db_connection.execute(""" @@ -4114,8 +4113,6 @@ def test_timeout_from_constructor(conn_str): def test_timeout_long_query(db_connection): """Test that a query exceeding the timeout raises an exception if supported by driver""" - import time - import pytest cursor = db_connection.cursor() diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index 811f1d18..9c54ae28 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -7822,6 +7822,132 @@ def test_datetimeoffset_malformed_input(cursor, db_connection): finally: cursor.execute("DROP TABLE IF EXISTS #pytest_datetimeoffset_malformed_input;") db_connection.commit() + +def test_datetimeoffset_executemany(cursor, db_connection): + """ + Test the driver's ability to correctly read and write DATETIMEOFFSET data + using executemany, including timezone information. + """ + try: + datetimeoffset_test_cases = [ + ( + "2023-10-26 10:30:00.0000000 +05:30", + datetime(2023, 10, 26, 10, 30, 0, 0, + tzinfo=timezone(timedelta(hours=5, minutes=30))) + ), + ( + "2023-10-27 15:45:10.1234567 -08:00", + datetime(2023, 10, 27, 15, 45, 10, 123456, + tzinfo=timezone(timedelta(hours=-8))) + ), + ( + "2023-10-28 20:00:05.9876543 +00:00", + datetime(2023, 10, 28, 20, 0, 5, 987654, + tzinfo=timezone(timedelta(hours=0))) + ) + ] + + # Create temp table + cursor.execute("IF OBJECT_ID('tempdb..#pytest_dto', 'U') IS NOT NULL DROP TABLE #pytest_dto;") + cursor.execute("CREATE TABLE #pytest_dto (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);") + db_connection.commit() + + # Prepare data for executemany + param_list = [(i, python_dt) for i, (_, python_dt) in enumerate(datetimeoffset_test_cases)] + cursor.executemany("INSERT INTO #pytest_dto (id, dto_column) VALUES (?, ?);", param_list) + db_connection.commit() + + # Read back and validate + cursor.execute("SELECT id, dto_column FROM #pytest_dto ORDER BY id;") + rows = cursor.fetchall() + + for i, (sql_str, python_dt) in enumerate(datetimeoffset_test_cases): + fetched_id, fetched_dto = rows[i] + assert fetched_dto.tzinfo is not None, "Fetched datetime object is naive." + + expected_utc = python_dt.astimezone(timezone.utc).replace(tzinfo=None) + fetched_utc = fetched_dto.astimezone(timezone.utc).replace(tzinfo=None) + + # Round microseconds to nearest millisecond for comparison + expected_utc = expected_utc.replace(microsecond=int(expected_utc.microsecond / 1000) * 1000) + fetched_utc = fetched_utc.replace(microsecond=int(fetched_utc.microsecond / 1000) * 1000) + + assert fetched_utc == expected_utc, ( + f"Value mismatch for test case {i}. " + f"Expected UTC: {expected_utc}, Got UTC: {fetched_utc}" + ) + finally: + cursor.execute("IF OBJECT_ID('tempdb..#pytest_dto', 'U') IS NOT NULL DROP TABLE #pytest_dto;") + db_connection.commit() + +def test_datetimeoffset_execute_vs_executemany_consistency(cursor, db_connection): + """ + Check that execute() and executemany() produce the same stored DATETIMEOFFSET + for identical timezone-aware datetime objects. + """ + try: + test_dt = datetime(2023, 10, 30, 12, 0, 0, microsecond=123456, + tzinfo=timezone(timedelta(hours=5, minutes=30))) + cursor.execute("IF OBJECT_ID('tempdb..#pytest_dto', 'U') IS NOT NULL DROP TABLE #pytest_dto;") + cursor.execute("CREATE TABLE #pytest_dto (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);") + db_connection.commit() + + # Insert using execute() + cursor.execute("INSERT INTO #pytest_dto (id, dto_column) VALUES (?, ?);", 1, test_dt) + db_connection.commit() + + # Insert using executemany() + cursor.executemany( + "INSERT INTO #pytest_dto (id, dto_column) VALUES (?, ?);", + [(2, test_dt)] + ) + db_connection.commit() + + cursor.execute("SELECT dto_column FROM #pytest_dto ORDER BY id;") + rows = cursor.fetchall() + assert len(rows) == 2 + + # Compare textual representation to ensure binding semantics match + cursor.execute("SELECT CONVERT(VARCHAR(35), dto_column, 127) FROM #pytest_dto ORDER BY id;") + textual_rows = [r[0] for r in cursor.fetchall()] + assert textual_rows[0] == textual_rows[1], "execute() and executemany() results differ" + + finally: + cursor.execute("IF OBJECT_ID('tempdb..#pytest_dto', 'U') IS NOT NULL DROP TABLE #pytest_dto;") + db_connection.commit() + + +def test_datetimeoffset_extreme_offsets(cursor, db_connection): + """ + Test boundary offsets (+14:00 and -12:00) to ensure correct round-trip handling. + """ + try: + extreme_offsets = [ + datetime(2023, 10, 30, 0, 0, 0, 0, tzinfo=timezone(timedelta(hours=14))), + datetime(2023, 10, 30, 0, 0, 0, 0, tzinfo=timezone(timedelta(hours=-12))), + ] + + cursor.execute("IF OBJECT_ID('tempdb..#pytest_dto', 'U') IS NOT NULL DROP TABLE #pytest_dto;") + cursor.execute("CREATE TABLE #pytest_dto (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);") + db_connection.commit() + + param_list = [(i, dt) for i, dt in enumerate(extreme_offsets)] + cursor.executemany("INSERT INTO #pytest_dto (id, dto_column) VALUES (?, ?);", param_list) + db_connection.commit() + + cursor.execute("SELECT id, dto_column FROM #pytest_dto ORDER BY id;") + rows = cursor.fetchall() + + for i, dt in enumerate(extreme_offsets): + _, fetched = rows[i] + assert fetched.tzinfo is not None + # Round-trip comparison via UTC + expected_utc = dt.astimezone(timezone.utc).replace(tzinfo=None) + fetched_utc = fetched.astimezone(timezone.utc).replace(tzinfo=None) + assert expected_utc == fetched_utc, f"Extreme offset round-trip failed for {dt.tzinfo}" + finally: + cursor.execute("IF OBJECT_ID('tempdb..#pytest_dto', 'U') IS NOT NULL DROP TABLE #pytest_dto;") + db_connection.commit() def test_lowercase_attribute(cursor, db_connection): """Test that the lowercase attribute properly converts column names to lowercase"""