Skip to content

Commit 7ed0734

Browse files
Fix: Move DATETIMEOFFSET handling to MSSQL connection initialization
1 parent 56421e9 commit 7ed0734

File tree

2 files changed

+159
-184
lines changed

2 files changed

+159
-184
lines changed

sqlmesh/core/config/connection.py

Lines changed: 27 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1520,41 +1520,6 @@ 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-
15581523
@property
15591524
def _connection_kwargs_keys(self) -> t.Set[str]:
15601525
base_keys = {
@@ -1659,7 +1624,33 @@ def connect(**kwargs: t.Any) -> t.Callable:
16591624
# Create the connection string
16601625
conn_str = ";".join(conn_str_parts)
16611626

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

16641655
return connect
16651656

tests/core/test_connection_config.py

Lines changed: 132 additions & 148 deletions
Original file line numberDiff line numberDiff line change
@@ -1523,182 +1523,166 @@ def test_mssql_pymssql_connection_factory():
15231523
del sys.modules["pymssql"]
15241524

15251525

1526-
def test_mssql_cursor_init_datetimeoffset_handling():
1527-
"""Test that the MSSQL cursor init properly handles DATETIMEOFFSET conversion."""
1526+
def test_mssql_pyodbc_connection_datetimeoffset_handling():
1527+
"""Test that the MSSQL pyodbc connection properly handles DATETIMEOFFSET conversion."""
15281528
from datetime import datetime, timezone, timedelta
15291529
import struct
1530-
from unittest.mock import Mock
1530+
from unittest.mock import Mock, patch
15311531

1532-
config = MSSQLConnectionConfig(
1533-
host="localhost",
1534-
driver="pyodbc", # DATETIMEOFFSET handling is pyodbc-specific
1535-
check_import=False,
1536-
)
1532+
with patch("pyodbc.connect") as mock_pyodbc_connect:
1533+
# Track calls to add_output_converter
1534+
converter_calls = []
15371535

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-
)
1536+
def mock_add_output_converter(sql_type, converter_func):
1537+
converter_calls.append((sql_type, converter_func))
15831538

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))
1539+
# Create a mock connection that will be returned by pyodbc.connect
1540+
mock_connection = Mock()
1541+
mock_connection.add_output_converter = mock_add_output_converter
1542+
mock_pyodbc_connect.return_value = mock_connection
1543+
1544+
config = MSSQLConnectionConfig(
1545+
host="localhost",
1546+
driver="pyodbc", # DATETIMEOFFSET handling is pyodbc-specific
1547+
check_import=False,
1548+
)
16001549

1550+
# Get the connection factory and call it
1551+
factory_with_kwargs = config._connection_factory_with_kwargs
1552+
connection = factory_with_kwargs()
1553+
1554+
# Verify that add_output_converter was called for SQL type -155 (DATETIMEOFFSET)
1555+
assert len(converter_calls) == 1
1556+
sql_type, converter_func = converter_calls[0]
1557+
assert sql_type == -155
1558+
1559+
# Test the converter function with actual DATETIMEOFFSET binary data
1560+
# Create a test DATETIMEOFFSET value: 2023-12-25 15:30:45.123456789 +05:30
1561+
year, month, day = 2023, 12, 25
1562+
hour, minute, second = 15, 30, 45
1563+
nanoseconds = 123456789
1564+
tz_hour_offset, tz_minute_offset = 5, 30
1565+
1566+
# Pack the binary data according to the DATETIMEOFFSET format
1567+
binary_data = struct.pack(
1568+
"<6hI2h",
1569+
year,
1570+
month,
1571+
day,
1572+
hour,
1573+
minute,
1574+
second,
1575+
nanoseconds,
1576+
tz_hour_offset,
1577+
tz_minute_offset,
1578+
)
1579+
1580+
# Convert using the registered converter
1581+
result = converter_func(binary_data)
1582+
1583+
# Verify the result
1584+
expected_dt = datetime(
1585+
2023,
1586+
12,
1587+
25,
1588+
15,
1589+
30,
1590+
45,
1591+
123456, # microseconds = nanoseconds // 1000
1592+
timezone(timedelta(hours=5, minutes=30)),
1593+
)
1594+
assert result == expected_dt
1595+
assert result.tzinfo == timezone(timedelta(hours=5, minutes=30))
16011596

1602-
def test_mssql_cursor_init_negative_timezone_offset():
1603-
"""Test DATETIMEOFFSET handling with negative timezone offset."""
1597+
1598+
def test_mssql_pyodbc_connection_negative_timezone_offset():
1599+
"""Test DATETIMEOFFSET handling with negative timezone offset at connection level."""
16041600
from datetime import datetime, timezone, timedelta
16051601
import struct
1606-
from unittest.mock import Mock
1602+
from unittest.mock import Mock, patch
16071603

1608-
config = MSSQLConnectionConfig(
1609-
host="localhost",
1610-
driver="pyodbc", # DATETIMEOFFSET handling is pyodbc-specific
1611-
check_import=False,
1612-
)
1604+
with patch("pyodbc.connect") as mock_pyodbc_connect:
1605+
converter_calls = []
16131606

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-
)
1607+
def mock_add_output_converter(sql_type, converter_func):
1608+
converter_calls.append((sql_type, converter_func))
16481609

1649-
result = converter_func(binary_data)
1610+
mock_connection = Mock()
1611+
mock_connection.add_output_converter = mock_add_output_converter
1612+
mock_pyodbc_connect.return_value = mock_connection
16501613

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))
1614+
config = MSSQLConnectionConfig(
1615+
host="localhost",
1616+
driver="pyodbc", # DATETIMEOFFSET handling is pyodbc-specific
1617+
check_import=False,
1618+
)
16541619

1620+
factory_with_kwargs = config._connection_factory_with_kwargs
1621+
connection = factory_with_kwargs()
16551622

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
1623+
# Get the converter function
1624+
_, converter_func = converter_calls[0]
1625+
1626+
# Test with negative timezone offset: 2023-01-01 12:00:00.0 -08:00
1627+
year, month, day = 2023, 1, 1
1628+
hour, minute, second = 12, 0, 0
1629+
nanoseconds = 0
1630+
tz_hour_offset, tz_minute_offset = -8, 0
1631+
1632+
binary_data = struct.pack(
1633+
"<6hI2h",
1634+
year,
1635+
month,
1636+
day,
1637+
hour,
1638+
minute,
1639+
second,
1640+
nanoseconds,
1641+
tz_hour_offset,
1642+
tz_minute_offset,
1643+
)
16591644

1660-
config = MSSQLConnectionConfig(
1661-
host="localhost",
1662-
driver="pyodbc", # DATETIMEOFFSET handling is pyodbc-specific
1663-
check_import=False,
1664-
)
1645+
result = converter_func(binary_data)
16651646

1666-
cursor_init = config._cursor_init
1667-
assert cursor_init is not None
1647+
expected_dt = datetime(2023, 1, 1, 12, 0, 0, 0, timezone(timedelta(hours=-8, minutes=0)))
1648+
assert result == expected_dt
1649+
assert result.tzinfo == timezone(timedelta(hours=-8))
16681650

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
16731651

1674-
# Remove the add_output_converter attribute
1675-
if hasattr(mock_connection, "add_output_converter"):
1676-
delattr(mock_connection, "add_output_converter")
1652+
def test_mssql_pyodbc_connection_no_add_output_converter():
1653+
"""Test that connection gracefully handles pyodbc without add_output_converter."""
1654+
from unittest.mock import Mock, patch
16771655

1678-
# This should not raise an exception
1679-
cursor_init(mock_cursor)
1656+
with patch("pyodbc.connect") as mock_pyodbc_connect:
1657+
# Create a mock connection without add_output_converter
1658+
mock_connection = Mock()
1659+
# Remove the add_output_converter attribute
1660+
if hasattr(mock_connection, "add_output_converter"):
1661+
delattr(mock_connection, "add_output_converter")
1662+
mock_pyodbc_connect.return_value = mock_connection
16801663

1664+
config = MSSQLConnectionConfig(
1665+
host="localhost",
1666+
driver="pyodbc", # DATETIMEOFFSET handling is pyodbc-specific
1667+
check_import=False,
1668+
)
16811669

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-
)
1670+
# This should not raise an exception
1671+
factory_with_kwargs = config._connection_factory_with_kwargs
1672+
connection = factory_with_kwargs()
16891673

1690-
cursor_init = config._cursor_init
1691-
assert cursor_init is not None
1692-
assert callable(cursor_init)
1674+
# Verify we get the connection back
1675+
assert connection is mock_connection
16931676

16941677

1695-
def test_mssql_cursor_init_returns_none_for_pymssql():
1696-
"""Test that _cursor_init returns None for pymssql driver."""
1678+
def test_mssql_no_cursor_init_for_pymssql():
1679+
"""Test that _cursor_init is not needed for pymssql driver."""
16971680
config = MSSQLConnectionConfig(
16981681
host="localhost",
16991682
driver="pymssql",
17001683
check_import=False,
17011684
)
17021685

1703-
cursor_init = config._cursor_init
1704-
assert cursor_init is None
1686+
# Since we moved output converter setup to connection level,
1687+
# there's no cursor init needed for any driver
1688+
assert not hasattr(config, "_cursor_init") or config._cursor_init is None

0 commit comments

Comments
 (0)