diff --git a/mssql_python/constants.py b/mssql_python/constants.py index 2e56112e..7bb4de15 100644 --- a/mssql_python/constants.py +++ b/mssql_python/constants.py @@ -124,6 +124,8 @@ class ConstantsDDBC(Enum): SQL_FETCH_ABSOLUTE = 5 SQL_FETCH_RELATIVE = 6 SQL_FETCH_BOOKMARK = 8 + SQL_DATETIMEOFFSET = -155 + SQL_C_SS_TIMESTAMPOFFSET = 0x4001 SQL_SCOPE_CURROW = 0 SQL_BEST_ROWID = 1 SQL_ROWVER = 2 diff --git a/mssql_python/cursor.py b/mssql_python/cursor.py index f0b3a9e4..6365d559 100644 --- a/mssql_python/cursor.py +++ b/mssql_python/cursor.py @@ -467,13 +467,24 @@ def _map_sql_type(self, param, parameters_list, i, min_val=None, max_val=None): ) if isinstance(param, datetime.datetime): - return ( - ddbc_sql_const.SQL_TIMESTAMP.value, - ddbc_sql_const.SQL_C_TYPE_TIMESTAMP.value, - 26, - 6, - False, - ) + if param.tzinfo is not None: + # Timezone-aware datetime -> DATETIMEOFFSET + return ( + ddbc_sql_const.SQL_DATETIMEOFFSET.value, + ddbc_sql_const.SQL_C_SS_TIMESTAMPOFFSET.value, + 34, + 7, + False, + ) + else: + # Naive datetime -> TIMESTAMP + return ( + ddbc_sql_const.SQL_TIMESTAMP.value, + ddbc_sql_const.SQL_C_TYPE_TIMESTAMP.value, + 26, + 6, + False, + ) if isinstance(param, datetime.date): return ( diff --git a/mssql_python/pybind/ddbc_bindings.cpp b/mssql_python/pybind/ddbc_bindings.cpp index a62866ee..e5c979b7 100644 --- a/mssql_python/pybind/ddbc_bindings.cpp +++ b/mssql_python/pybind/ddbc_bindings.cpp @@ -12,14 +12,14 @@ #include #include // std::forward #include - //------------------------------------------------------------------------------------------------- // Macro definitions //------------------------------------------------------------------------------------------------- // This constant is not exposed via sql.h, hence define it here #define SQL_SS_TIME2 (-154) - +#define SQL_SS_TIMESTAMPOFFSET (-155) +#define SQL_C_SS_TIMESTAMPOFFSET (0x4001) #define MAX_DIGITS_IN_NUMERIC 64 #define STRINGIFY_FOR_CASE(x) \ @@ -94,6 +94,20 @@ struct ColumnBuffers { 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 //------------------------------------------------------------------------------------------------- @@ -463,6 +477,49 @@ SQLRETURN BindParameters(SQLHANDLE hStmt, const py::list& params, dataPtr = static_cast(sqlTimePtr); break; } + case SQL_C_SS_TIMESTAMPOFFSET: { + py::object datetimeType = py::module_::import("datetime").attr("datetime"); + if (!py::isinstance(param, datetimeType)) { + ThrowStdException(MakeParamMismatchErrorStr(paramInfo.paramCType, paramIndex)); + } + // Checking if the object has a timezone + 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)); + } + + DateTimeOffset* dtoPtr = AllocateParamBuffer(paramBuffers); + + dtoPtr->year = static_cast(param.attr("year").cast()); + dtoPtr->month = static_cast(param.attr("month").cast()); + dtoPtr->day = static_cast(param.attr("day").cast()); + dtoPtr->hour = static_cast(param.attr("hour").cast()); + dtoPtr->minute = static_cast(param.attr("minute").cast()); + dtoPtr->second = static_cast(param.attr("second").cast()); + dtoPtr->fraction = static_cast(param.attr("microsecond").cast() * 1000); + + py::object utcoffset = tzinfo.attr("utcoffset")(param); + if (utcoffset.is_none()) { + ThrowStdException("Datetime object's tzinfo.utcoffset() returned None at paramIndex " + std::to_string(paramIndex)); + } + + int total_seconds = static_cast(utcoffset.attr("total_seconds")().cast()); + const int MAX_OFFSET = 14 * 3600; + const int MIN_OFFSET = -14 * 3600; + + if (total_seconds > MAX_OFFSET || total_seconds < MIN_OFFSET) { + ThrowStdException("Datetimeoffset tz offset out of SQL Server range (-14h to +14h) at paramIndex " + std::to_string(paramIndex)); + } + std::div_t div_result = std::div(total_seconds, 3600); + dtoPtr->timezone_hour = static_cast(div_result.quot); + dtoPtr->timezone_minute = static_cast(div(div_result.rem, 60).quot); + + dataPtr = static_cast(dtoPtr); + bufferLength = sizeof(DateTimeOffset); + strLenOrIndPtr = AllocateParamBuffer(paramBuffers); + *strLenOrIndPtr = bufferLength; + break; + } case SQL_C_TYPE_TIMESTAMP: { py::object datetimeType = py::module_::import("datetime").attr("datetime"); if (!py::isinstance(param, datetimeType)) { @@ -540,7 +597,6 @@ SQLRETURN BindParameters(SQLHANDLE hStmt, const py::list& params, } } assert(SQLBindParameter_ptr && SQLGetStmtAttr_ptr && SQLSetDescField_ptr); - RETCODE rc = SQLBindParameter_ptr( hStmt, static_cast(paramIndex + 1), /* 1-based indexing */ @@ -2511,6 +2567,55 @@ SQLRETURN SQLGetData_wrap(SqlHandlePtr StatementHandle, SQLUSMALLINT colCount, p } break; } + case SQL_SS_TIMESTAMPOFFSET: { + DateTimeOffset dtoValue; + SQLLEN indicator; + ret = SQLGetData_ptr( + hStmt, + i, SQL_C_SS_TIMESTAMPOFFSET, + &dtoValue, + sizeof(dtoValue), + &indicator + ); + if (SQL_SUCCEEDED(ret) && indicator != SQL_NULL_DATA) { + LOG("[Fetch] Retrieved DTO: {}-{}-{} {}:{}:{}, fraction(ns)={}, tz_hour={}, tz_minute={}", + dtoValue.year, dtoValue.month, dtoValue.day, + dtoValue.hour, dtoValue.minute, dtoValue.second, + dtoValue.fraction, + dtoValue.timezone_hour, dtoValue.timezone_minute + ); + + int totalMinutes = dtoValue.timezone_hour * 60 + dtoValue.timezone_minute; + // Validating offset + if (totalMinutes < -24 * 60 || totalMinutes > 24 * 60) { + std::ostringstream oss; + oss << "Invalid timezone offset from SQL_SS_TIMESTAMPOFFSET_STRUCT: " + << totalMinutes << " minutes for column " << i; + ThrowStdException(oss.str()); + } + // Convert fraction from ns to µs + int microseconds = dtoValue.fraction / 1000; + 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, + microseconds, + tzinfo + ); + row.append(py_dt); + } else { + LOG("Error fetching DATETIMEOFFSET for column {}, ret={}", i, ret); + row.append(py::none()); + } + break; + } case SQL_BINARY: case SQL_VARBINARY: case SQL_LONGVARBINARY: { diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index 6b28a378..0b53d449 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -9,7 +9,7 @@ """ import pytest -from datetime import datetime, date, time +from datetime import datetime, date, time, timedelta, timezone import time as time_module import decimal from contextlib import closing @@ -6472,7 +6472,7 @@ def test_only_null_and_empty_binary(cursor, db_connection): finally: drop_table_if_exists(cursor, "#pytest_null_empty_binary") db_connection.commit() - + # ---------------------- VARCHAR(MAX) ---------------------- def test_varcharmax_short_fetch(cursor, db_connection): @@ -7560,6 +7560,169 @@ def test_decimal_separator_calculations(cursor, db_connection): cursor.execute("DROP TABLE IF EXISTS #pytest_decimal_calc_test") db_connection.commit() +def test_datetimeoffset_read_write(cursor, db_connection): + """Test reading and writing timezone-aware DATETIMEOFFSET values.""" + try: + test_cases = [ + # Valid timezone-aware datetimes + datetime(2023, 10, 26, 10, 30, 0, tzinfo=timezone(timedelta(hours=5, minutes=30))), + datetime(2023, 10, 27, 15, 45, 10, 123456, tzinfo=timezone(timedelta(hours=-8))), + datetime(2023, 10, 28, 20, 0, 5, 987654, tzinfo=timezone.utc) + ] + + cursor.execute("CREATE TABLE #pytest_datetimeoffset_read_write (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);") + db_connection.commit() + + insert_stmt = "INSERT INTO #pytest_datetimeoffset_read_write (id, dto_column) VALUES (?, ?);" + for i, dt in enumerate(test_cases): + cursor.execute(insert_stmt, i, dt) + db_connection.commit() + + cursor.execute("SELECT id, dto_column FROM #pytest_datetimeoffset_read_write ORDER BY id;") + for i, dt in enumerate(test_cases): + row = cursor.fetchone() + assert row is not None + fetched_id, fetched_dt = row + assert fetched_dt.tzinfo is not None + expected_utc = dt.astimezone(timezone.utc) + fetched_utc = fetched_dt.astimezone(timezone.utc) + # Ignore sub-microsecond differences + 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 + finally: + cursor.execute("DROP TABLE IF EXISTS #pytest_datetimeoffset_read_write;") + db_connection.commit() + +def test_datetimeoffset_max_min_offsets(cursor, db_connection): + """ + Test inserting and retrieving DATETIMEOFFSET with maximum and minimum allowed offsets (+14:00 and -14:00). + Uses fetchone() for retrieval. + """ + try: + cursor.execute("CREATE TABLE #pytest_datetimeoffset_read_write (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);") + db_connection.commit() + + test_cases = [ + (1, datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone(timedelta(hours=14)))), # max offset + (2, datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone(timedelta(hours=-14)))), # min offset + ] + + insert_stmt = "INSERT INTO #pytest_datetimeoffset_read_write (id, dto_column) VALUES (?, ?);" + for row_id, dt in test_cases: + cursor.execute(insert_stmt, row_id, dt) + db_connection.commit() + + cursor.execute("SELECT id, dto_column FROM #pytest_datetimeoffset_read_write ORDER BY id;") + + for expected_id, expected_dt in test_cases: + row = cursor.fetchone() + assert row is not None, f"No row fetched for id {expected_id}." + fetched_id, fetched_dt = row + + assert fetched_id == expected_id, f"ID mismatch: expected {expected_id}, got {fetched_id}" + assert fetched_dt.tzinfo is not None, f"Fetched datetime object is naive for id {fetched_id}" + + # Compare in UTC to avoid offset differences + expected_utc = expected_dt.astimezone(timezone.utc).replace(tzinfo=None) + fetched_utc = fetched_dt.astimezone(timezone.utc).replace(tzinfo=None) + assert fetched_utc == expected_utc, ( + f"Value mismatch for id {expected_id}: expected UTC {expected_utc}, got {fetched_utc}" + ) + + finally: + cursor.execute("DROP TABLE IF EXISTS #pytest_datetimeoffset_read_write;") + db_connection.commit() + +def test_datetimeoffset_invalid_offsets(cursor, db_connection): + """Verify driver rejects offsets beyond ±14 hours.""" + try: + cursor.execute("CREATE TABLE #pytest_datetimeoffset_invalid_offsets (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);") + db_connection.commit() + + with pytest.raises(Exception): + cursor.execute("INSERT INTO #pytest_datetimeoffset_invalid_offsets (id, dto_column) VALUES (?, ?);", + 1, datetime(2025, 1, 1, 12, 0, tzinfo=timezone(timedelta(hours=15)))) + + with pytest.raises(Exception): + cursor.execute("INSERT INTO #pytest_datetimeoffset_invalid_offsets (id, dto_column) VALUES (?, ?);", + 2, datetime(2025, 1, 1, 12, 0, tzinfo=timezone(timedelta(hours=-15)))) + finally: + cursor.execute("DROP TABLE IF EXISTS #pytest_datetimeoffset_invalid_offsets;") + db_connection.commit() + +def test_datetimeoffset_dst_transitions(cursor, db_connection): + """ + Test inserting and retrieving DATETIMEOFFSET values around DST transitions. + Ensures that driver handles DST correctly and does not crash. + """ + try: + cursor.execute("CREATE TABLE #pytest_datetimeoffset_dst_transitions (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);") + db_connection.commit() + + # Example DST transition dates (replace with actual region offset if needed) + dst_test_cases = [ + (1, datetime(2025, 3, 9, 1, 59, 59, tzinfo=timezone(timedelta(hours=-5)))), # Just before spring forward + (2, datetime(2025, 3, 9, 3, 0, 0, tzinfo=timezone(timedelta(hours=-4)))), # Just after spring forward + (3, datetime(2025, 11, 2, 1, 59, 59, tzinfo=timezone(timedelta(hours=-4)))), # Just before fall back + (4, datetime(2025, 11, 2, 1, 0, 0, tzinfo=timezone(timedelta(hours=-5)))), # Just after fall back + ] + + insert_stmt = "INSERT INTO #pytest_datetimeoffset_dst_transitions (id, dto_column) VALUES (?, ?);" + for row_id, dt in dst_test_cases: + cursor.execute(insert_stmt, row_id, dt) + db_connection.commit() + + cursor.execute("SELECT id, dto_column FROM #pytest_datetimeoffset_dst_transitions ORDER BY id;") + + for expected_id, expected_dt in dst_test_cases: + row = cursor.fetchone() + assert row is not None, f"No row fetched for id {expected_id}." + fetched_id, fetched_dt = row + + assert fetched_id == expected_id, f"ID mismatch: expected {expected_id}, got {fetched_id}" + assert fetched_dt.tzinfo is not None, f"Fetched datetime object is naive for id {fetched_id}" + + # Compare UTC time to avoid issues due to offsets changing in DST + expected_utc = expected_dt.astimezone(timezone.utc).replace(tzinfo=None) + fetched_utc = fetched_dt.astimezone(timezone.utc).replace(tzinfo=None) + assert fetched_utc == expected_utc, ( + f"Value mismatch for id {expected_id}: expected UTC {expected_utc}, got {fetched_utc}" + ) + + finally: + cursor.execute("DROP TABLE IF EXISTS #pytest_datetimeoffset_dst_transitions;") + db_connection.commit() + +def test_datetimeoffset_leap_second(cursor, db_connection): + """Ensure driver handles leap-second-like microsecond edge cases without crashing.""" + try: + cursor.execute("CREATE TABLE #pytest_datetimeoffset_leap_second (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);") + db_connection.commit() + + leap_second_sim = datetime(2023, 12, 31, 23, 59, 59, 999999, tzinfo=timezone.utc) + cursor.execute("INSERT INTO #pytest_datetimeoffset_leap_second (id, dto_column) VALUES (?, ?);", 1, leap_second_sim) + db_connection.commit() + + row = cursor.execute("SELECT dto_column FROM #pytest_datetimeoffset_leap_second;").fetchone() + assert row[0].tzinfo is not None + finally: + cursor.execute("DROP TABLE IF EXISTS #pytest_datetimeoffset_leap_second;") + db_connection.commit() + +def test_datetimeoffset_malformed_input(cursor, db_connection): + """Verify driver raises error for invalid datetimeoffset strings.""" + try: + cursor.execute("CREATE TABLE #pytest_datetimeoffset_malformed_input (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);") + db_connection.commit() + + with pytest.raises(Exception): + cursor.execute("INSERT INTO #pytest_datetimeoffset_malformed_input (id, dto_column) VALUES (?, ?);", + 1, "2023-13-45 25:61:00 +99:99") # invalid string + finally: + cursor.execute("DROP TABLE IF EXISTS #pytest_datetimeoffset_malformed_input;") + db_connection.commit() + def test_lowercase_attribute(cursor, db_connection): """Test that the lowercase attribute properly converts column names to lowercase"""