Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 2 additions & 36 deletions mssql_python/cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand Down
Binary file removed mssql_python/msvcp140.dll
Binary file not shown.
116 changes: 102 additions & 14 deletions mssql_python/pybind/ddbc_bindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::vector<SQLCHAR>> charBuffers;
Expand All @@ -78,6 +92,7 @@ struct ColumnBuffers {
std::vector<std::vector<SQL_TIME_STRUCT>> timeBuffers;
std::vector<std::vector<SQLGUID>> guidBuffers;
std::vector<std::vector<SQLLEN>> indicators;
std::vector<std::vector<DateTimeOffset>> datetimeoffsetBuffers;

ColumnBuffers(SQLSMALLINT numCols, int fetchSize)
: charBuffers(numCols),
Expand All @@ -91,23 +106,10 @@ struct ColumnBuffers {
dateBuffers(numCols),
timeBuffers(numCols),
guidBuffers(numCols),
datetimeoffsetBuffers(numCols),
indicators(numCols, std::vector<SQLLEN>(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
//-------------------------------------------------------------------------------------------------
Expand Down Expand Up @@ -496,6 +498,7 @@ SQLRETURN BindParameters(SQLHANDLE hStmt, const py::list& params,
dtoPtr->hour = static_cast<SQLUSMALLINT>(param.attr("hour").cast<int>());
dtoPtr->minute = static_cast<SQLUSMALLINT>(param.attr("minute").cast<int>());
dtoPtr->second = static_cast<SQLUSMALLINT>(param.attr("second").cast<int>());
// SQL server supports in ns, but python datetime supports in µs
dtoPtr->fraction = static_cast<SQLUINTEGER>(param.attr("microsecond").cast<int>() * 1000);

py::object utcoffset = tzinfo.attr("utcoffset")(param);
Expand Down Expand Up @@ -1934,6 +1937,53 @@ SQLRETURN BindParameterArray(SQLHANDLE hStmt,
bufferLength = sizeof(SQL_TIMESTAMP_STRUCT);
break;
}
case SQL_C_SS_TIMESTAMPOFFSET: {
DateTimeOffset* dtoArray = AllocateParamBufferArray<DateTimeOffset>(tempBuffers, paramSetSize);
strLenOrIndArray = AllocateParamBufferArray<SQLLEN>(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<SQLSMALLINT>(param.attr("year").cast<int>());
dtoArray[i].month = static_cast<SQLUSMALLINT>(param.attr("month").cast<int>());
dtoArray[i].day = static_cast<SQLUSMALLINT>(param.attr("day").cast<int>());
dtoArray[i].hour = static_cast<SQLUSMALLINT>(param.attr("hour").cast<int>());
dtoArray[i].minute = static_cast<SQLUSMALLINT>(param.attr("minute").cast<int>());
dtoArray[i].second = static_cast<SQLUSMALLINT>(param.attr("second").cast<int>());
// SQL server supports in ns, but python datetime supports in µs
dtoArray[i].fraction = static_cast<SQLUINTEGER>(param.attr("microsecond").cast<int>() * 1000);

// Compute and preserve the original UTC offset.
py::object utcoffset = tzinfo.attr("utcoffset")(param);
int total_seconds = static_cast<int>(utcoffset.attr("total_seconds")().cast<double>());
std::div_t div_result = std::div(total_seconds, 3600);
dtoArray[i].timezone_hour = static_cast<SQLSMALLINT>(div_result.quot);
dtoArray[i].timezone_minute = static_cast<SQLSMALLINT>(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<SQL_NUMERIC_STRUCT>(tempBuffers, paramSetSize);
strLenOrIndArray = AllocateParamBufferArray<SQLLEN>(tempBuffers, paramSetSize);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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::wstring>();
std::ostringstream errorString;
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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::wstring>();
std::ostringstream errorString;
Expand Down
3 changes: 0 additions & 3 deletions tests/test_003_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("""
Expand Down Expand Up @@ -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()

Expand Down
126 changes: 126 additions & 0 deletions tests/test_004_cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand Down