Skip to content

Commit 7b91e8f

Browse files
authored
FEAT: datetimeoffset support in executemany() (#260)
### Work Item / Issue Reference <!-- IMPORTANT: Please follow the PR template guidelines below. For mssql-python maintainers: Insert your ADO Work Item ID below (e.g. AB#37452) For external contributors: Insert Github Issue number below (e.g. #149) Only one reference is required - either GitHub issue OR ADO Work Item. --> <!-- mssql-python maintainers: ADO Work Item --> > [AB#34944](https://sqlclientdrivers.visualstudio.com/c6d89619-62de-46a0-8b46-70b92a84d85e/_workitems/edit/34944) <!-- External contributors: GitHub Issue --> > GitHub Issue: #<ISSUE_NUMBER> ------------------------------------------------------------------- ### Summary <!-- Insert your summary of changes below. Minimum 10 characters required. --> This pull request adds comprehensive support for the SQL Server `DATETIMEOFFSET` type in both the Python and C++ layers of the codebase. It introduces new constants, enhances the datetime parsing logic to handle timezone-aware datetimes, and updates the parameter binding and data fetching logic to correctly handle `DATETIMEOFFSET` values. The changes ensure accurate round-trip of timezone-aware datetime values between Python and SQL Server. **Support for DATETIMEOFFSET type:** * Added `SQL_DATETIMEOFFSET` and `SQL_C_SS_TIMESTAMPOFFSET` constants to both `mssql_python/constants.py` and the C++ bindings (`ddbc_bindings.cpp`). [[1]](diffhunk://#diff-e6d80f1000af6fd5afca05f435b11fd82df7f5c3e75ecf5763f85d3aacdbe758R127-R128) [[2]](diffhunk://#diff-dde2297345718ec449a14e7dff91b7bb2342b008ecc071f562233646d71144a1L15-R22) * Introduced a new `DateTimeOffset` C++ struct and integrated it into buffer management for column data. [[1]](diffhunk://#diff-dde2297345718ec449a14e7dff91b7bb2342b008ecc071f562233646d71144a1R67-R80) [[2]](diffhunk://#diff-dde2297345718ec449a14e7dff91b7bb2342b008ecc071f562233646d71144a1R95) [[3]](diffhunk://#diff-dde2297345718ec449a14e7dff91b7bb2342b008ecc071f562233646d71144a1R109) * Updated the C++ parameter binding logic to correctly marshal Python timezone-aware `datetime` objects as `DATETIMEOFFSET`, including array binding for `executemany`. [[1]](diffhunk://#diff-dde2297345718ec449a14e7dff91b7bb2342b008ecc071f562233646d71144a1R482-R514) [[2]](diffhunk://#diff-dde2297345718ec449a14e7dff91b7bb2342b008ecc071f562233646d71144a1R1881) [[3]](diffhunk://#diff-dde2297345718ec449a14e7dff91b7bb2342b008ecc071f562233646d71144a1R1904-R1964) * Implemented fetching and conversion logic for `DATETIMEOFFSET` columns, ensuring Python receives timezone-aware `datetime` objects in UTC. [[1]](diffhunk://#diff-dde2297345718ec449a14e7dff91b7bb2342b008ecc071f562233646d71144a1R2598-R2647) [[2]](diffhunk://#diff-dde2297345718ec449a14e7dff91b7bb2342b008ecc071f562233646d71144a1R2904-R2910) [[3]](diffhunk://#diff-dde2297345718ec449a14e7dff91b7bb2342b008ecc071f562233646d71144a1R3126-R3162) **Datetime parsing and mapping improvements:** * Enhanced `_parse_datetime` in `mssql_python/cursor.py` to support parsing timezone-aware datetime strings, improving compatibility with `DATETIMEOFFSET`. * Updated SQL type mapping logic to distinguish between naive and timezone-aware `datetime` objects, mapping the latter to `DATETIMEOFFSET`. **Refactoring and cleanup:** * Removed the now-unused `_select_best_sample_value` static method from `mssql_python/cursor.py` as type inference is now handled differently. * Adjusted type inference during `executemany` to use the new `_compute_column_type` method, aligning with the improved datetime handling. <!-- ### PR Title Guide > For feature requests FEAT: (short-description) > For non-feature requests like test case updates, config updates , dependency updates etc CHORE: (short-description) > For Fix requests FIX: (short-description) > For doc update requests DOC: (short-description) > For Formatting, indentation, or styling update STYLE: (short-description) > For Refactor, without any feature changes REFACTOR: (short-description) > For release related changes, without any feature changes RELEASE: #<RELEASE_VERSION> (short-description) ### Contribution Guidelines External contributors: - Create a GitHub issue first: https://github.com/microsoft/mssql-python/issues/new - Link the GitHub issue in the "GitHub Issue" section above - Follow the PR title format and provide a meaningful summary mssql-python maintainers: - Create an ADO Work Item following internal processes - Link the ADO Work Item in the "ADO Work Item" section above - Follow the PR title format and provide a meaningful summary -->
1 parent f0b5959 commit 7b91e8f

File tree

5 files changed

+230
-53
lines changed

5 files changed

+230
-53
lines changed

mssql_python/cursor.py

Lines changed: 2 additions & 36 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.
@@ -1442,35 +1442,6 @@ def columns(self, table=None, catalog=None, schema=None, column=None):
14421442
# Use the helper method to prepare the result set
14431443
return self._prepare_metadata_result_set(fallback_description=fallback_description)
14441444

1445-
@staticmethod
1446-
def _select_best_sample_value(column):
1447-
"""
1448-
Selects the most representative non-null value from a column for type inference.
1449-
1450-
This is used during executemany() to infer SQL/C types based on actual data,
1451-
preferring a non-null value that is not the first row to avoid bias from placeholder defaults.
1452-
1453-
Args:
1454-
column: List of values in the column.
1455-
"""
1456-
non_nulls = [v for v in column if v is not None]
1457-
if not non_nulls:
1458-
return None
1459-
if all(isinstance(v, int) for v in non_nulls):
1460-
# Pick the value with the widest range (min/max)
1461-
return max(non_nulls, key=lambda v: abs(v))
1462-
if all(isinstance(v, float) for v in non_nulls):
1463-
return 0.0
1464-
if all(isinstance(v, decimal.Decimal) for v in non_nulls):
1465-
return max(non_nulls, key=lambda d: len(d.as_tuple().digits))
1466-
if all(isinstance(v, str) for v in non_nulls):
1467-
return max(non_nulls, key=lambda s: len(str(s)))
1468-
if all(isinstance(v, datetime.datetime) for v in non_nulls):
1469-
return datetime.datetime.now()
1470-
if all(isinstance(v, datetime.date) for v in non_nulls):
1471-
return datetime.date.today()
1472-
return non_nulls[0] # fallback
1473-
14741445
def _transpose_rowwise_to_columnwise(self, seq_of_parameters: list) -> tuple[list, int]:
14751446
"""
14761447
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:
16411612
else:
16421613
# Use auto-detection for columns without explicit types
16431614
column = [row[col_index] for row in seq_of_parameters] if hasattr(seq_of_parameters, '__getitem__') else []
1644-
if not column:
1645-
# For generators, use the sample row for inference
1646-
sample_value = sample_row[col_index]
1647-
else:
1648-
sample_value = self._select_best_sample_value(column)
1649-
1615+
sample_value, min_val, max_val = self._compute_column_type(column)
16501616
dummy_row = list(sample_row)
16511617
paraminfo = self._create_parameter_types_list(
16521618
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
@@ -7822,6 +7822,132 @@ def test_datetimeoffset_malformed_input(cursor, db_connection):
78227822
finally:
78237823
cursor.execute("DROP TABLE IF EXISTS #pytest_datetimeoffset_malformed_input;")
78247824
db_connection.commit()
7825+
7826+
def test_datetimeoffset_executemany(cursor, db_connection):
7827+
"""
7828+
Test the driver's ability to correctly read and write DATETIMEOFFSET data
7829+
using executemany, including timezone information.
7830+
"""
7831+
try:
7832+
datetimeoffset_test_cases = [
7833+
(
7834+
"2023-10-26 10:30:00.0000000 +05:30",
7835+
datetime(2023, 10, 26, 10, 30, 0, 0,
7836+
tzinfo=timezone(timedelta(hours=5, minutes=30)))
7837+
),
7838+
(
7839+
"2023-10-27 15:45:10.1234567 -08:00",
7840+
datetime(2023, 10, 27, 15, 45, 10, 123456,
7841+
tzinfo=timezone(timedelta(hours=-8)))
7842+
),
7843+
(
7844+
"2023-10-28 20:00:05.9876543 +00:00",
7845+
datetime(2023, 10, 28, 20, 0, 5, 987654,
7846+
tzinfo=timezone(timedelta(hours=0)))
7847+
)
7848+
]
7849+
7850+
# Create temp table
7851+
cursor.execute("IF OBJECT_ID('tempdb..#pytest_dto', 'U') IS NOT NULL DROP TABLE #pytest_dto;")
7852+
cursor.execute("CREATE TABLE #pytest_dto (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);")
7853+
db_connection.commit()
7854+
7855+
# Prepare data for executemany
7856+
param_list = [(i, python_dt) for i, (_, python_dt) in enumerate(datetimeoffset_test_cases)]
7857+
cursor.executemany("INSERT INTO #pytest_dto (id, dto_column) VALUES (?, ?);", param_list)
7858+
db_connection.commit()
7859+
7860+
# Read back and validate
7861+
cursor.execute("SELECT id, dto_column FROM #pytest_dto ORDER BY id;")
7862+
rows = cursor.fetchall()
7863+
7864+
for i, (sql_str, python_dt) in enumerate(datetimeoffset_test_cases):
7865+
fetched_id, fetched_dto = rows[i]
7866+
assert fetched_dto.tzinfo is not None, "Fetched datetime object is naive."
7867+
7868+
expected_utc = python_dt.astimezone(timezone.utc).replace(tzinfo=None)
7869+
fetched_utc = fetched_dto.astimezone(timezone.utc).replace(tzinfo=None)
7870+
7871+
# Round microseconds to nearest millisecond for comparison
7872+
expected_utc = expected_utc.replace(microsecond=int(expected_utc.microsecond / 1000) * 1000)
7873+
fetched_utc = fetched_utc.replace(microsecond=int(fetched_utc.microsecond / 1000) * 1000)
7874+
7875+
assert fetched_utc == expected_utc, (
7876+
f"Value mismatch for test case {i}. "
7877+
f"Expected UTC: {expected_utc}, Got UTC: {fetched_utc}"
7878+
)
7879+
finally:
7880+
cursor.execute("IF OBJECT_ID('tempdb..#pytest_dto', 'U') IS NOT NULL DROP TABLE #pytest_dto;")
7881+
db_connection.commit()
7882+
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()
78257951

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

0 commit comments

Comments
 (0)