Skip to content

Commit 1475216

Browse files
committed
Fix: Add DATETIMEOFFSET handling for MSSQL cursor initialization
1 parent d1dc55c commit 1475216

File tree

2 files changed

+216
-0
lines changed

2 files changed

+216
-0
lines changed

sqlmesh/core/config/connection.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1520,6 +1520,41 @@ def _mssql_engine_import_validator(cls, data: t.Any) -> t.Any:
15201520
# Call the raw validation function directly
15211521
return validator_func(cls, data)
15221522

1523+
@property
1524+
def _cursor_init(self) -> t.Optional[t.Callable[[t.Any], None]]:
1525+
"""Initialize the cursor with output converters for MSSQL-specific data types."""
1526+
# Only apply pyodbc-specific cursor initialization when using pyodbc driver
1527+
if self.driver != "pyodbc":
1528+
return None
1529+
1530+
def init(cursor: t.Any) -> None:
1531+
# Get the connection from the cursor and set the output converter
1532+
conn = cursor.connection
1533+
if hasattr(conn, "add_output_converter"):
1534+
# Handle SQL type -155 (DATETIMEOFFSET) which is not yet supported by pyodbc
1535+
# ref: https://github.com/mkleehammer/pyodbc/issues/134#issuecomment-281739794
1536+
def handle_datetimeoffset(dto_value: t.Any) -> t.Any:
1537+
from datetime import datetime, timedelta, timezone
1538+
import struct
1539+
1540+
# Unpack the DATETIMEOFFSET binary format:
1541+
# Format: <6hI2h = (year, month, day, hour, minute, second, nanoseconds, tz_hour_offset, tz_minute_offset)
1542+
tup = struct.unpack("<6hI2h", dto_value)
1543+
return datetime(
1544+
tup[0],
1545+
tup[1],
1546+
tup[2],
1547+
tup[3],
1548+
tup[4],
1549+
tup[5],
1550+
tup[6] // 1000,
1551+
timezone(timedelta(hours=tup[7], minutes=tup[8])),
1552+
)
1553+
1554+
conn.add_output_converter(-155, handle_datetimeoffset)
1555+
1556+
return init
1557+
15231558
@property
15241559
def _connection_kwargs_keys(self) -> t.Set[str]:
15251560
base_keys = {

tests/core/test_connection_config.py

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1521,3 +1521,184 @@ def test_mssql_pymssql_connection_factory():
15211521
# Clean up the mock module
15221522
if "pymssql" in sys.modules:
15231523
del sys.modules["pymssql"]
1524+
1525+
1526+
def test_mssql_cursor_init_datetimeoffset_handling():
1527+
"""Test that the MSSQL cursor init properly handles DATETIMEOFFSET conversion."""
1528+
from datetime import datetime, timezone, timedelta
1529+
import struct
1530+
from unittest.mock import Mock
1531+
1532+
config = MSSQLConnectionConfig(
1533+
host="localhost",
1534+
driver="pyodbc", # DATETIMEOFFSET handling is pyodbc-specific
1535+
check_import=False,
1536+
)
1537+
1538+
# Get the cursor init function
1539+
cursor_init = config._cursor_init
1540+
assert cursor_init is not None
1541+
1542+
# Create a mock cursor and connection
1543+
mock_connection = Mock()
1544+
mock_cursor = Mock()
1545+
mock_cursor.connection = mock_connection
1546+
1547+
# Track calls to add_output_converter
1548+
converter_calls = []
1549+
1550+
def mock_add_output_converter(sql_type, converter_func):
1551+
converter_calls.append((sql_type, converter_func))
1552+
1553+
mock_connection.add_output_converter = mock_add_output_converter
1554+
1555+
# Call the cursor init function
1556+
cursor_init(mock_cursor)
1557+
1558+
# Verify that add_output_converter was called for SQL type -155 (DATETIMEOFFSET)
1559+
assert len(converter_calls) == 1
1560+
sql_type, converter_func = converter_calls[0]
1561+
assert sql_type == -155
1562+
1563+
# Test the converter function with actual DATETIMEOFFSET binary data
1564+
# Create a test DATETIMEOFFSET value: 2023-12-25 15:30:45.123456789 +05:30
1565+
year, month, day = 2023, 12, 25
1566+
hour, minute, second = 15, 30, 45
1567+
nanoseconds = 123456789
1568+
tz_hour_offset, tz_minute_offset = 5, 30
1569+
1570+
# Pack the binary data according to the DATETIMEOFFSET format
1571+
binary_data = struct.pack(
1572+
"<6hI2h",
1573+
year,
1574+
month,
1575+
day,
1576+
hour,
1577+
minute,
1578+
second,
1579+
nanoseconds,
1580+
tz_hour_offset,
1581+
tz_minute_offset,
1582+
)
1583+
1584+
# Convert using the registered converter
1585+
result = converter_func(binary_data)
1586+
1587+
# Verify the result
1588+
expected_dt = datetime(
1589+
2023,
1590+
12,
1591+
25,
1592+
15,
1593+
30,
1594+
45,
1595+
123456, # microseconds = nanoseconds // 1000
1596+
timezone(timedelta(hours=5, minutes=30)),
1597+
)
1598+
assert result == expected_dt
1599+
assert result.tzinfo == timezone(timedelta(hours=5, minutes=30))
1600+
1601+
1602+
def test_mssql_cursor_init_negative_timezone_offset():
1603+
"""Test DATETIMEOFFSET handling with negative timezone offset."""
1604+
from datetime import datetime, timezone, timedelta
1605+
import struct
1606+
from unittest.mock import Mock
1607+
1608+
config = MSSQLConnectionConfig(
1609+
host="localhost",
1610+
driver="pyodbc", # DATETIMEOFFSET handling is pyodbc-specific
1611+
check_import=False,
1612+
)
1613+
1614+
cursor_init = config._cursor_init
1615+
mock_connection = Mock()
1616+
mock_cursor = Mock()
1617+
mock_cursor.connection = mock_connection
1618+
1619+
converter_calls = []
1620+
1621+
def mock_add_output_converter(sql_type, converter_func):
1622+
converter_calls.append((sql_type, converter_func))
1623+
1624+
mock_connection.add_output_converter = mock_add_output_converter
1625+
cursor_init(mock_cursor)
1626+
1627+
# Get the converter function
1628+
_, converter_func = converter_calls[0]
1629+
1630+
# Test with negative timezone offset: 2023-01-01 12:00:00.0 -08:00
1631+
year, month, day = 2023, 1, 1
1632+
hour, minute, second = 12, 0, 0
1633+
nanoseconds = 0
1634+
tz_hour_offset, tz_minute_offset = -8, 0
1635+
1636+
binary_data = struct.pack(
1637+
"<6hI2h",
1638+
year,
1639+
month,
1640+
day,
1641+
hour,
1642+
minute,
1643+
second,
1644+
nanoseconds,
1645+
tz_hour_offset,
1646+
tz_minute_offset,
1647+
)
1648+
1649+
result = converter_func(binary_data)
1650+
1651+
expected_dt = datetime(2023, 1, 1, 12, 0, 0, 0, timezone(timedelta(hours=-8, minutes=0)))
1652+
assert result == expected_dt
1653+
assert result.tzinfo == timezone(timedelta(hours=-8))
1654+
1655+
1656+
def test_mssql_cursor_init_no_add_output_converter():
1657+
"""Test that cursor init gracefully handles connections without add_output_converter."""
1658+
from unittest.mock import Mock
1659+
1660+
config = MSSQLConnectionConfig(
1661+
host="localhost",
1662+
driver="pyodbc", # DATETIMEOFFSET handling is pyodbc-specific
1663+
check_import=False,
1664+
)
1665+
1666+
cursor_init = config._cursor_init
1667+
assert cursor_init is not None
1668+
1669+
# Create a mock cursor and connection without add_output_converter
1670+
mock_connection = Mock()
1671+
mock_cursor = Mock()
1672+
mock_cursor.connection = mock_connection
1673+
1674+
# Remove the add_output_converter attribute
1675+
if hasattr(mock_connection, "add_output_converter"):
1676+
delattr(mock_connection, "add_output_converter")
1677+
1678+
# This should not raise an exception
1679+
cursor_init(mock_cursor)
1680+
1681+
1682+
def test_mssql_cursor_init_returns_callable_for_pyodbc():
1683+
"""Test that _cursor_init returns a callable function for pyodbc driver."""
1684+
config = MSSQLConnectionConfig(
1685+
host="localhost",
1686+
driver="pyodbc",
1687+
check_import=False,
1688+
)
1689+
1690+
cursor_init = config._cursor_init
1691+
assert cursor_init is not None
1692+
assert callable(cursor_init)
1693+
1694+
1695+
def test_mssql_cursor_init_returns_none_for_pymssql():
1696+
"""Test that _cursor_init returns None for pymssql driver."""
1697+
config = MSSQLConnectionConfig(
1698+
host="localhost",
1699+
driver="pymssql",
1700+
check_import=False,
1701+
)
1702+
1703+
cursor_init = config._cursor_init
1704+
assert cursor_init is None

0 commit comments

Comments
 (0)