Skip to content

Commit f05ee81

Browse files
authored
Merge branch 'main' into saumya/uuid-executemany
2 parents 00147d6 + fba0e63 commit f05ee81

File tree

4 files changed

+293
-12
lines changed

4 files changed

+293
-12
lines changed

mssql_python/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@ class ConstantsDDBC(Enum):
124124
SQL_FETCH_ABSOLUTE = 5
125125
SQL_FETCH_RELATIVE = 6
126126
SQL_FETCH_BOOKMARK = 8
127+
SQL_DATETIMEOFFSET = -155
128+
SQL_C_SS_TIMESTAMPOFFSET = 0x4001
127129
SQL_SCOPE_CURROW = 0
128130
SQL_BEST_ROWID = 1
129131
SQL_ROWVER = 2

mssql_python/cursor.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -476,13 +476,24 @@ def _map_sql_type(self, param, parameters_list, i, min_val=None, max_val=None):
476476
)
477477

478478
if isinstance(param, datetime.datetime):
479-
return (
480-
ddbc_sql_const.SQL_TIMESTAMP.value,
481-
ddbc_sql_const.SQL_C_TYPE_TIMESTAMP.value,
482-
26,
483-
6,
484-
False,
485-
)
479+
if param.tzinfo is not None:
480+
# Timezone-aware datetime -> DATETIMEOFFSET
481+
return (
482+
ddbc_sql_const.SQL_DATETIMEOFFSET.value,
483+
ddbc_sql_const.SQL_C_SS_TIMESTAMPOFFSET.value,
484+
34,
485+
7,
486+
False,
487+
)
488+
else:
489+
# Naive datetime -> TIMESTAMP
490+
return (
491+
ddbc_sql_const.SQL_TIMESTAMP.value,
492+
ddbc_sql_const.SQL_C_TYPE_TIMESTAMP.value,
493+
26,
494+
6,
495+
False,
496+
)
486497

487498
if isinstance(param, datetime.date):
488499
return (

mssql_python/pybind/ddbc_bindings.cpp

Lines changed: 108 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,14 @@
1212
#include <iostream>
1313
#include <utility> // std::forward
1414
#include <filesystem>
15-
1615
//-------------------------------------------------------------------------------------------------
1716
// Macro definitions
1817
//-------------------------------------------------------------------------------------------------
1918

2019
// This constant is not exposed via sql.h, hence define it here
2120
#define SQL_SS_TIME2 (-154)
22-
21+
#define SQL_SS_TIMESTAMPOFFSET (-155)
22+
#define SQL_C_SS_TIMESTAMPOFFSET (0x4001)
2323
#define MAX_DIGITS_IN_NUMERIC 64
2424

2525
#define STRINGIFY_FOR_CASE(x) \
@@ -94,6 +94,20 @@ struct ColumnBuffers {
9494
indicators(numCols, std::vector<SQLLEN>(fetchSize)) {}
9595
};
9696

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+
97111
//-------------------------------------------------------------------------------------------------
98112
// Function pointer initialization
99113
//-------------------------------------------------------------------------------------------------
@@ -463,6 +477,49 @@ SQLRETURN BindParameters(SQLHANDLE hStmt, const py::list& params,
463477
dataPtr = static_cast<void*>(sqlTimePtr);
464478
break;
465479
}
480+
case SQL_C_SS_TIMESTAMPOFFSET: {
481+
py::object datetimeType = py::module_::import("datetime").attr("datetime");
482+
if (!py::isinstance(param, datetimeType)) {
483+
ThrowStdException(MakeParamMismatchErrorStr(paramInfo.paramCType, paramIndex));
484+
}
485+
// Checking if the object has a timezone
486+
py::object tzinfo = param.attr("tzinfo");
487+
if (tzinfo.is_none()) {
488+
ThrowStdException("Datetime object must have tzinfo for SQL_C_SS_TIMESTAMPOFFSET at paramIndex " + std::to_string(paramIndex));
489+
}
490+
491+
DateTimeOffset* dtoPtr = AllocateParamBuffer<DateTimeOffset>(paramBuffers);
492+
493+
dtoPtr->year = static_cast<SQLSMALLINT>(param.attr("year").cast<int>());
494+
dtoPtr->month = static_cast<SQLUSMALLINT>(param.attr("month").cast<int>());
495+
dtoPtr->day = static_cast<SQLUSMALLINT>(param.attr("day").cast<int>());
496+
dtoPtr->hour = static_cast<SQLUSMALLINT>(param.attr("hour").cast<int>());
497+
dtoPtr->minute = static_cast<SQLUSMALLINT>(param.attr("minute").cast<int>());
498+
dtoPtr->second = static_cast<SQLUSMALLINT>(param.attr("second").cast<int>());
499+
dtoPtr->fraction = static_cast<SQLUINTEGER>(param.attr("microsecond").cast<int>() * 1000);
500+
501+
py::object utcoffset = tzinfo.attr("utcoffset")(param);
502+
if (utcoffset.is_none()) {
503+
ThrowStdException("Datetime object's tzinfo.utcoffset() returned None at paramIndex " + std::to_string(paramIndex));
504+
}
505+
506+
int total_seconds = static_cast<int>(utcoffset.attr("total_seconds")().cast<double>());
507+
const int MAX_OFFSET = 14 * 3600;
508+
const int MIN_OFFSET = -14 * 3600;
509+
510+
if (total_seconds > MAX_OFFSET || total_seconds < MIN_OFFSET) {
511+
ThrowStdException("Datetimeoffset tz offset out of SQL Server range (-14h to +14h) at paramIndex " + std::to_string(paramIndex));
512+
}
513+
std::div_t div_result = std::div(total_seconds, 3600);
514+
dtoPtr->timezone_hour = static_cast<SQLSMALLINT>(div_result.quot);
515+
dtoPtr->timezone_minute = static_cast<SQLSMALLINT>(div(div_result.rem, 60).quot);
516+
517+
dataPtr = static_cast<void*>(dtoPtr);
518+
bufferLength = sizeof(DateTimeOffset);
519+
strLenOrIndPtr = AllocateParamBuffer<SQLLEN>(paramBuffers);
520+
*strLenOrIndPtr = bufferLength;
521+
break;
522+
}
466523
case SQL_C_TYPE_TIMESTAMP: {
467524
py::object datetimeType = py::module_::import("datetime").attr("datetime");
468525
if (!py::isinstance(param, datetimeType)) {
@@ -540,7 +597,6 @@ SQLRETURN BindParameters(SQLHANDLE hStmt, const py::list& params,
540597
}
541598
}
542599
assert(SQLBindParameter_ptr && SQLGetStmtAttr_ptr && SQLSetDescField_ptr);
543-
544600
RETCODE rc = SQLBindParameter_ptr(
545601
hStmt,
546602
static_cast<SQLUSMALLINT>(paramIndex + 1), /* 1-based indexing */
@@ -2549,6 +2605,55 @@ SQLRETURN SQLGetData_wrap(SqlHandlePtr StatementHandle, SQLUSMALLINT colCount, p
25492605
}
25502606
break;
25512607
}
2608+
case SQL_SS_TIMESTAMPOFFSET: {
2609+
DateTimeOffset dtoValue;
2610+
SQLLEN indicator;
2611+
ret = SQLGetData_ptr(
2612+
hStmt,
2613+
i, SQL_C_SS_TIMESTAMPOFFSET,
2614+
&dtoValue,
2615+
sizeof(dtoValue),
2616+
&indicator
2617+
);
2618+
if (SQL_SUCCEEDED(ret) && indicator != SQL_NULL_DATA) {
2619+
LOG("[Fetch] Retrieved DTO: {}-{}-{} {}:{}:{}, fraction(ns)={}, tz_hour={}, tz_minute={}",
2620+
dtoValue.year, dtoValue.month, dtoValue.day,
2621+
dtoValue.hour, dtoValue.minute, dtoValue.second,
2622+
dtoValue.fraction,
2623+
dtoValue.timezone_hour, dtoValue.timezone_minute
2624+
);
2625+
2626+
int totalMinutes = dtoValue.timezone_hour * 60 + dtoValue.timezone_minute;
2627+
// Validating offset
2628+
if (totalMinutes < -24 * 60 || totalMinutes > 24 * 60) {
2629+
std::ostringstream oss;
2630+
oss << "Invalid timezone offset from SQL_SS_TIMESTAMPOFFSET_STRUCT: "
2631+
<< totalMinutes << " minutes for column " << i;
2632+
ThrowStdException(oss.str());
2633+
}
2634+
// Convert fraction from ns to µs
2635+
int microseconds = dtoValue.fraction / 1000;
2636+
py::object datetime = py::module_::import("datetime");
2637+
py::object tzinfo = datetime.attr("timezone")(
2638+
datetime.attr("timedelta")(py::arg("minutes") = totalMinutes)
2639+
);
2640+
py::object py_dt = datetime.attr("datetime")(
2641+
dtoValue.year,
2642+
dtoValue.month,
2643+
dtoValue.day,
2644+
dtoValue.hour,
2645+
dtoValue.minute,
2646+
dtoValue.second,
2647+
microseconds,
2648+
tzinfo
2649+
);
2650+
row.append(py_dt);
2651+
} else {
2652+
LOG("Error fetching DATETIMEOFFSET for column {}, ret={}", i, ret);
2653+
row.append(py::none());
2654+
}
2655+
break;
2656+
}
25522657
case SQL_BINARY:
25532658
case SQL_VARBINARY:
25542659
case SQL_LONGVARBINARY: {

tests/test_004_cursor.py

Lines changed: 165 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"""
1010

1111
import pytest
12-
from datetime import datetime, date, time
12+
from datetime import datetime, date, time, timedelta, timezone
1313
import time as time_module
1414
import decimal
1515
from contextlib import closing
@@ -6471,7 +6471,7 @@ def test_only_null_and_empty_binary(cursor, db_connection):
64716471
finally:
64726472
drop_table_if_exists(cursor, "#pytest_null_empty_binary")
64736473
db_connection.commit()
6474-
6474+
64756475
# ---------------------- VARCHAR(MAX) ----------------------
64766476

64776477
def test_varcharmax_short_fetch(cursor, db_connection):
@@ -7559,6 +7559,169 @@ def test_decimal_separator_calculations(cursor, db_connection):
75597559
cursor.execute("DROP TABLE IF EXISTS #pytest_decimal_calc_test")
75607560
db_connection.commit()
75617561

7562+
def test_datetimeoffset_read_write(cursor, db_connection):
7563+
"""Test reading and writing timezone-aware DATETIMEOFFSET values."""
7564+
try:
7565+
test_cases = [
7566+
# Valid timezone-aware datetimes
7567+
datetime(2023, 10, 26, 10, 30, 0, tzinfo=timezone(timedelta(hours=5, minutes=30))),
7568+
datetime(2023, 10, 27, 15, 45, 10, 123456, tzinfo=timezone(timedelta(hours=-8))),
7569+
datetime(2023, 10, 28, 20, 0, 5, 987654, tzinfo=timezone.utc)
7570+
]
7571+
7572+
cursor.execute("CREATE TABLE #pytest_datetimeoffset_read_write (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);")
7573+
db_connection.commit()
7574+
7575+
insert_stmt = "INSERT INTO #pytest_datetimeoffset_read_write (id, dto_column) VALUES (?, ?);"
7576+
for i, dt in enumerate(test_cases):
7577+
cursor.execute(insert_stmt, i, dt)
7578+
db_connection.commit()
7579+
7580+
cursor.execute("SELECT id, dto_column FROM #pytest_datetimeoffset_read_write ORDER BY id;")
7581+
for i, dt in enumerate(test_cases):
7582+
row = cursor.fetchone()
7583+
assert row is not None
7584+
fetched_id, fetched_dt = row
7585+
assert fetched_dt.tzinfo is not None
7586+
expected_utc = dt.astimezone(timezone.utc)
7587+
fetched_utc = fetched_dt.astimezone(timezone.utc)
7588+
# Ignore sub-microsecond differences
7589+
expected_utc = expected_utc.replace(microsecond=int(expected_utc.microsecond / 1000) * 1000)
7590+
fetched_utc = fetched_utc.replace(microsecond=int(fetched_utc.microsecond / 1000) * 1000)
7591+
assert fetched_utc == expected_utc
7592+
finally:
7593+
cursor.execute("DROP TABLE IF EXISTS #pytest_datetimeoffset_read_write;")
7594+
db_connection.commit()
7595+
7596+
def test_datetimeoffset_max_min_offsets(cursor, db_connection):
7597+
"""
7598+
Test inserting and retrieving DATETIMEOFFSET with maximum and minimum allowed offsets (+14:00 and -14:00).
7599+
Uses fetchone() for retrieval.
7600+
"""
7601+
try:
7602+
cursor.execute("CREATE TABLE #pytest_datetimeoffset_read_write (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);")
7603+
db_connection.commit()
7604+
7605+
test_cases = [
7606+
(1, datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone(timedelta(hours=14)))), # max offset
7607+
(2, datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone(timedelta(hours=-14)))), # min offset
7608+
]
7609+
7610+
insert_stmt = "INSERT INTO #pytest_datetimeoffset_read_write (id, dto_column) VALUES (?, ?);"
7611+
for row_id, dt in test_cases:
7612+
cursor.execute(insert_stmt, row_id, dt)
7613+
db_connection.commit()
7614+
7615+
cursor.execute("SELECT id, dto_column FROM #pytest_datetimeoffset_read_write ORDER BY id;")
7616+
7617+
for expected_id, expected_dt in test_cases:
7618+
row = cursor.fetchone()
7619+
assert row is not None, f"No row fetched for id {expected_id}."
7620+
fetched_id, fetched_dt = row
7621+
7622+
assert fetched_id == expected_id, f"ID mismatch: expected {expected_id}, got {fetched_id}"
7623+
assert fetched_dt.tzinfo is not None, f"Fetched datetime object is naive for id {fetched_id}"
7624+
7625+
# Compare in UTC to avoid offset differences
7626+
expected_utc = expected_dt.astimezone(timezone.utc).replace(tzinfo=None)
7627+
fetched_utc = fetched_dt.astimezone(timezone.utc).replace(tzinfo=None)
7628+
assert fetched_utc == expected_utc, (
7629+
f"Value mismatch for id {expected_id}: expected UTC {expected_utc}, got {fetched_utc}"
7630+
)
7631+
7632+
finally:
7633+
cursor.execute("DROP TABLE IF EXISTS #pytest_datetimeoffset_read_write;")
7634+
db_connection.commit()
7635+
7636+
def test_datetimeoffset_invalid_offsets(cursor, db_connection):
7637+
"""Verify driver rejects offsets beyond ±14 hours."""
7638+
try:
7639+
cursor.execute("CREATE TABLE #pytest_datetimeoffset_invalid_offsets (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);")
7640+
db_connection.commit()
7641+
7642+
with pytest.raises(Exception):
7643+
cursor.execute("INSERT INTO #pytest_datetimeoffset_invalid_offsets (id, dto_column) VALUES (?, ?);",
7644+
1, datetime(2025, 1, 1, 12, 0, tzinfo=timezone(timedelta(hours=15))))
7645+
7646+
with pytest.raises(Exception):
7647+
cursor.execute("INSERT INTO #pytest_datetimeoffset_invalid_offsets (id, dto_column) VALUES (?, ?);",
7648+
2, datetime(2025, 1, 1, 12, 0, tzinfo=timezone(timedelta(hours=-15))))
7649+
finally:
7650+
cursor.execute("DROP TABLE IF EXISTS #pytest_datetimeoffset_invalid_offsets;")
7651+
db_connection.commit()
7652+
7653+
def test_datetimeoffset_dst_transitions(cursor, db_connection):
7654+
"""
7655+
Test inserting and retrieving DATETIMEOFFSET values around DST transitions.
7656+
Ensures that driver handles DST correctly and does not crash.
7657+
"""
7658+
try:
7659+
cursor.execute("CREATE TABLE #pytest_datetimeoffset_dst_transitions (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);")
7660+
db_connection.commit()
7661+
7662+
# Example DST transition dates (replace with actual region offset if needed)
7663+
dst_test_cases = [
7664+
(1, datetime(2025, 3, 9, 1, 59, 59, tzinfo=timezone(timedelta(hours=-5)))), # Just before spring forward
7665+
(2, datetime(2025, 3, 9, 3, 0, 0, tzinfo=timezone(timedelta(hours=-4)))), # Just after spring forward
7666+
(3, datetime(2025, 11, 2, 1, 59, 59, tzinfo=timezone(timedelta(hours=-4)))), # Just before fall back
7667+
(4, datetime(2025, 11, 2, 1, 0, 0, tzinfo=timezone(timedelta(hours=-5)))), # Just after fall back
7668+
]
7669+
7670+
insert_stmt = "INSERT INTO #pytest_datetimeoffset_dst_transitions (id, dto_column) VALUES (?, ?);"
7671+
for row_id, dt in dst_test_cases:
7672+
cursor.execute(insert_stmt, row_id, dt)
7673+
db_connection.commit()
7674+
7675+
cursor.execute("SELECT id, dto_column FROM #pytest_datetimeoffset_dst_transitions ORDER BY id;")
7676+
7677+
for expected_id, expected_dt in dst_test_cases:
7678+
row = cursor.fetchone()
7679+
assert row is not None, f"No row fetched for id {expected_id}."
7680+
fetched_id, fetched_dt = row
7681+
7682+
assert fetched_id == expected_id, f"ID mismatch: expected {expected_id}, got {fetched_id}"
7683+
assert fetched_dt.tzinfo is not None, f"Fetched datetime object is naive for id {fetched_id}"
7684+
7685+
# Compare UTC time to avoid issues due to offsets changing in DST
7686+
expected_utc = expected_dt.astimezone(timezone.utc).replace(tzinfo=None)
7687+
fetched_utc = fetched_dt.astimezone(timezone.utc).replace(tzinfo=None)
7688+
assert fetched_utc == expected_utc, (
7689+
f"Value mismatch for id {expected_id}: expected UTC {expected_utc}, got {fetched_utc}"
7690+
)
7691+
7692+
finally:
7693+
cursor.execute("DROP TABLE IF EXISTS #pytest_datetimeoffset_dst_transitions;")
7694+
db_connection.commit()
7695+
7696+
def test_datetimeoffset_leap_second(cursor, db_connection):
7697+
"""Ensure driver handles leap-second-like microsecond edge cases without crashing."""
7698+
try:
7699+
cursor.execute("CREATE TABLE #pytest_datetimeoffset_leap_second (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);")
7700+
db_connection.commit()
7701+
7702+
leap_second_sim = datetime(2023, 12, 31, 23, 59, 59, 999999, tzinfo=timezone.utc)
7703+
cursor.execute("INSERT INTO #pytest_datetimeoffset_leap_second (id, dto_column) VALUES (?, ?);", 1, leap_second_sim)
7704+
db_connection.commit()
7705+
7706+
row = cursor.execute("SELECT dto_column FROM #pytest_datetimeoffset_leap_second;").fetchone()
7707+
assert row[0].tzinfo is not None
7708+
finally:
7709+
cursor.execute("DROP TABLE IF EXISTS #pytest_datetimeoffset_leap_second;")
7710+
db_connection.commit()
7711+
7712+
def test_datetimeoffset_malformed_input(cursor, db_connection):
7713+
"""Verify driver raises error for invalid datetimeoffset strings."""
7714+
try:
7715+
cursor.execute("CREATE TABLE #pytest_datetimeoffset_malformed_input (id INT PRIMARY KEY, dto_column DATETIMEOFFSET);")
7716+
db_connection.commit()
7717+
7718+
with pytest.raises(Exception):
7719+
cursor.execute("INSERT INTO #pytest_datetimeoffset_malformed_input (id, dto_column) VALUES (?, ?);",
7720+
1, "2023-13-45 25:61:00 +99:99") # invalid string
7721+
finally:
7722+
cursor.execute("DROP TABLE IF EXISTS #pytest_datetimeoffset_malformed_input;")
7723+
db_connection.commit()
7724+
75627725
def test_lowercase_attribute(cursor, db_connection):
75637726
"""Test that the lowercase attribute properly converts column names to lowercase"""
75647727

0 commit comments

Comments
 (0)