Skip to content

Commit d6dc077

Browse files
Fix: Move DATETIMEOFFSET handling to MSSQL connection initialization
1 parent 07a8d93 commit d6dc077

2 files changed

Lines changed: 158 additions & 183 deletions

File tree

sqlmesh/core/config/connection.py

Lines changed: 27 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1523,41 +1523,6 @@ 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-
15611526
@property
15621527
def _connection_kwargs_keys(self) -> t.Set[str]:
15631528
base_keys = {
@@ -1662,7 +1627,33 @@ def connect(**kwargs: t.Any) -> t.Callable:
16621627
# Create the connection string
16631628
conn_str = ";".join(conn_str_parts)
16641629

1665-
return pyodbc.connect(conn_str, autocommit=kwargs.get("autocommit", False))
1630+
conn = pyodbc.connect(conn_str, autocommit=kwargs.get("autocommit", False))
1631+
1632+
# Set up output converters for MSSQL-specific data types
1633+
if hasattr(conn, "add_output_converter"):
1634+
# Handle SQL type -155 (DATETIMEOFFSET) which is not yet supported by pyodbc
1635+
# ref: https://github.com/mkleehammer/pyodbc/issues/134#issuecomment-281739794
1636+
def handle_datetimeoffset(dto_value: t.Any) -> t.Any:
1637+
from datetime import datetime, timedelta, timezone
1638+
import struct
1639+
1640+
# Unpack the DATETIMEOFFSET binary format:
1641+
# Format: <6hI2h = (year, month, day, hour, minute, second, nanoseconds, tz_hour_offset, tz_minute_offset)
1642+
tup = struct.unpack("<6hI2h", dto_value)
1643+
return datetime(
1644+
tup[0],
1645+
tup[1],
1646+
tup[2],
1647+
tup[3],
1648+
tup[4],
1649+
tup[5],
1650+
tup[6] // 1000,
1651+
timezone(timedelta(hours=tup[7], minutes=tup[8])),
1652+
)
1653+
1654+
conn.add_output_converter(-155, handle_datetimeoffset)
1655+
1656+
return conn
16661657

16671658
return connect
16681659

tests/core/test_connection_config.py

Lines changed: 131 additions & 147 deletions
Original file line numberDiff line numberDiff line change
@@ -1559,182 +1559,166 @@ def test_mssql_pymssql_connection_factory():
15591559
del sys.modules["pymssql"]
15601560

15611561

1562-
def test_mssql_cursor_init_datetimeoffset_handling():
1563-
"""Test that the MSSQL cursor init properly handles DATETIMEOFFSET conversion."""
1562+
def test_mssql_pyodbc_connection_datetimeoffset_handling():
1563+
"""Test that the MSSQL pyodbc connection properly handles DATETIMEOFFSET conversion."""
15641564
from datetime import datetime, timezone, timedelta
15651565
import struct
1566-
from unittest.mock import Mock
1566+
from unittest.mock import Mock, patch
15671567

1568-
config = MSSQLConnectionConfig(
1569-
host="localhost",
1570-
driver="pyodbc", # DATETIMEOFFSET handling is pyodbc-specific
1571-
check_import=False,
1572-
)
1568+
with patch("pyodbc.connect") as mock_pyodbc_connect:
1569+
# Track calls to add_output_converter
1570+
converter_calls = []
15731571

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
1572+
def mock_add_output_converter(sql_type, converter_func):
1573+
converter_calls.append((sql_type, converter_func))
16431574

1644-
config = MSSQLConnectionConfig(
1645-
host="localhost",
1646-
driver="pyodbc", # DATETIMEOFFSET handling is pyodbc-specific
1647-
check_import=False,
1648-
)
1575+
# Create a mock connection that will be returned by pyodbc.connect
1576+
mock_connection = Mock()
1577+
mock_connection.add_output_converter = mock_add_output_converter
1578+
mock_pyodbc_connect.return_value = mock_connection
16491579

1650-
cursor_init = config._cursor_init
1651-
mock_connection = Mock()
1652-
mock_cursor = Mock()
1653-
mock_cursor.connection = mock_connection
1580+
config = MSSQLConnectionConfig(
1581+
host="localhost",
1582+
driver="pyodbc", # DATETIMEOFFSET handling is pyodbc-specific
1583+
check_import=False,
1584+
)
16541585

1655-
converter_calls = []
1586+
# Get the connection factory and call it
1587+
factory_with_kwargs = config._connection_factory_with_kwargs
1588+
connection = factory_with_kwargs()
16561589

1657-
def mock_add_output_converter(sql_type, converter_func):
1658-
converter_calls.append((sql_type, converter_func))
1590+
# Verify that add_output_converter was called for SQL type -155 (DATETIMEOFFSET)
1591+
assert len(converter_calls) == 1
1592+
sql_type, converter_func = converter_calls[0]
1593+
assert sql_type == -155
1594+
1595+
# Test the converter function with actual DATETIMEOFFSET binary data
1596+
# Create a test DATETIMEOFFSET value: 2023-12-25 15:30:45.123456789 +05:30
1597+
year, month, day = 2023, 12, 25
1598+
hour, minute, second = 15, 30, 45
1599+
nanoseconds = 123456789
1600+
tz_hour_offset, tz_minute_offset = 5, 30
1601+
1602+
# Pack the binary data according to the DATETIMEOFFSET format
1603+
binary_data = struct.pack(
1604+
"<6hI2h",
1605+
year,
1606+
month,
1607+
day,
1608+
hour,
1609+
minute,
1610+
second,
1611+
nanoseconds,
1612+
tz_hour_offset,
1613+
tz_minute_offset,
1614+
)
16591615

1660-
mock_connection.add_output_converter = mock_add_output_converter
1661-
cursor_init(mock_cursor)
1616+
# Convert using the registered converter
1617+
result = converter_func(binary_data)
1618+
1619+
# Verify the result
1620+
expected_dt = datetime(
1621+
2023,
1622+
12,
1623+
25,
1624+
15,
1625+
30,
1626+
45,
1627+
123456, # microseconds = nanoseconds // 1000
1628+
timezone(timedelta(hours=5, minutes=30)),
1629+
)
1630+
assert result == expected_dt
1631+
assert result.tzinfo == timezone(timedelta(hours=5, minutes=30))
16621632

1663-
# Get the converter function
1664-
_, converter_func = converter_calls[0]
16651633

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
1634+
def test_mssql_pyodbc_connection_negative_timezone_offset():
1635+
"""Test DATETIMEOFFSET handling with negative timezone offset at connection level."""
1636+
from datetime import datetime, timezone, timedelta
1637+
import struct
1638+
from unittest.mock import Mock, patch
16711639

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-
)
1640+
with patch("pyodbc.connect") as mock_pyodbc_connect:
1641+
converter_calls = []
16841642

1685-
result = converter_func(binary_data)
1643+
def mock_add_output_converter(sql_type, converter_func):
1644+
converter_calls.append((sql_type, converter_func))
16861645

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))
1646+
mock_connection = Mock()
1647+
mock_connection.add_output_converter = mock_add_output_converter
1648+
mock_pyodbc_connect.return_value = mock_connection
16901649

1650+
config = MSSQLConnectionConfig(
1651+
host="localhost",
1652+
driver="pyodbc", # DATETIMEOFFSET handling is pyodbc-specific
1653+
check_import=False,
1654+
)
16911655

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
1656+
factory_with_kwargs = config._connection_factory_with_kwargs
1657+
connection = factory_with_kwargs()
16951658

1696-
config = MSSQLConnectionConfig(
1697-
host="localhost",
1698-
driver="pyodbc", # DATETIMEOFFSET handling is pyodbc-specific
1699-
check_import=False,
1700-
)
1659+
# Get the converter function
1660+
_, converter_func = converter_calls[0]
1661+
1662+
# Test with negative timezone offset: 2023-01-01 12:00:00.0 -08:00
1663+
year, month, day = 2023, 1, 1
1664+
hour, minute, second = 12, 0, 0
1665+
nanoseconds = 0
1666+
tz_hour_offset, tz_minute_offset = -8, 0
1667+
1668+
binary_data = struct.pack(
1669+
"<6hI2h",
1670+
year,
1671+
month,
1672+
day,
1673+
hour,
1674+
minute,
1675+
second,
1676+
nanoseconds,
1677+
tz_hour_offset,
1678+
tz_minute_offset,
1679+
)
17011680

1702-
cursor_init = config._cursor_init
1703-
assert cursor_init is not None
1681+
result = converter_func(binary_data)
17041682

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
1683+
expected_dt = datetime(2023, 1, 1, 12, 0, 0, 0, timezone(timedelta(hours=-8, minutes=0)))
1684+
assert result == expected_dt
1685+
assert result.tzinfo == timezone(timedelta(hours=-8))
17091686

1710-
# Remove the add_output_converter attribute
1711-
if hasattr(mock_connection, "add_output_converter"):
1712-
delattr(mock_connection, "add_output_converter")
17131687

1714-
# This should not raise an exception
1715-
cursor_init(mock_cursor)
1688+
def test_mssql_pyodbc_connection_no_add_output_converter():
1689+
"""Test that connection gracefully handles pyodbc without add_output_converter."""
1690+
from unittest.mock import Mock, patch
17161691

1692+
with patch("pyodbc.connect") as mock_pyodbc_connect:
1693+
# Create a mock connection without add_output_converter
1694+
mock_connection = Mock()
1695+
# Remove the add_output_converter attribute
1696+
if hasattr(mock_connection, "add_output_converter"):
1697+
delattr(mock_connection, "add_output_converter")
1698+
mock_pyodbc_connect.return_value = mock_connection
17171699

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-
)
1700+
config = MSSQLConnectionConfig(
1701+
host="localhost",
1702+
driver="pyodbc", # DATETIMEOFFSET handling is pyodbc-specific
1703+
check_import=False,
1704+
)
1705+
1706+
# This should not raise an exception
1707+
factory_with_kwargs = config._connection_factory_with_kwargs
1708+
connection = factory_with_kwargs()
17251709

1726-
cursor_init = config._cursor_init
1727-
assert cursor_init is not None
1728-
assert callable(cursor_init)
1710+
# Verify we get the connection back
1711+
assert connection is mock_connection
17291712

17301713

1731-
def test_mssql_cursor_init_returns_none_for_pymssql():
1732-
"""Test that _cursor_init returns None for pymssql driver."""
1714+
def test_mssql_no_cursor_init_for_pymssql():
1715+
"""Test that _cursor_init is not needed for pymssql driver."""
17331716
config = MSSQLConnectionConfig(
17341717
host="localhost",
17351718
driver="pymssql",
17361719
check_import=False,
17371720
)
17381721

1739-
cursor_init = config._cursor_init
1740-
assert cursor_init is None
1722+
# Since we moved output converter setup to connection level,
1723+
# there's no cursor init needed for any driver
1724+
assert not hasattr(config, "_cursor_init") or config._cursor_init is None

0 commit comments

Comments
 (0)