Skip to content

Commit 07a8d93

Browse files
Fix: Add DATETIMEOFFSET handling for MSSQL cursor initialization
1 parent 8421488 commit 07a8d93

2 files changed

Lines changed: 216 additions & 0 deletions

File tree

sqlmesh/core/config/connection.py

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

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

tests/core/test_connection_config.py

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

0 commit comments

Comments
 (0)