Skip to content

Commit 461b2a2

Browse files
fix!: Add DATETIMEOFFSET handling in pyodbc for MSSQL (#4930)
1 parent 0d6193d commit 461b2a2

File tree

3 files changed

+155
-4
lines changed

3 files changed

+155
-4
lines changed

pyproject.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ classifiers = [
4040
[project.optional-dependencies]
4141
athena = ["PyAthena[Pandas]"]
4242
azuresql = ["pymssql"]
43-
azuresql-odbc = ["pyodbc"]
43+
azuresql-odbc = ["pyodbc>=5.0.0"]
4444
bigquery = [
4545
"google-cloud-bigquery[pandas]",
4646
"google-cloud-bigquery-storage"
@@ -78,7 +78,7 @@ dev = [
7878
"pydantic",
7979
"PyAthena[Pandas]",
8080
"PyGithub>=2.6.0",
81-
"pyodbc",
81+
"pyodbc>=5.0.0",
8282
"pyperf",
8383
"pyspark~=3.5.0",
8484
"pytest",
@@ -108,7 +108,7 @@ github = ["PyGithub~=2.5.0"]
108108
llm = ["langchain", "openai"]
109109
motherduck = ["duckdb>=1.2.0"]
110110
mssql = ["pymssql"]
111-
mssql-odbc = ["pyodbc"]
111+
mssql-odbc = ["pyodbc>=5.0.0"]
112112
mysql = ["pymysql"]
113113
mwaa = ["boto3"]
114114
postgres = ["psycopg2"]

sqlmesh/core/config/connection.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1627,7 +1627,32 @@ def connect(**kwargs: t.Any) -> t.Callable:
16271627
# Create the connection string
16281628
conn_str = ";".join(conn_str_parts)
16291629

1630-
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+
# Handle SQL type -155 (DATETIMEOFFSET) which is not yet supported by pyodbc
1634+
# ref: https://github.com/mkleehammer/pyodbc/issues/134#issuecomment-281739794
1635+
def handle_datetimeoffset(dto_value: t.Any) -> t.Any:
1636+
from datetime import datetime, timedelta, timezone
1637+
import struct
1638+
1639+
# Unpack the DATETIMEOFFSET binary format:
1640+
# Format: <6hI2h = (year, month, day, hour, minute, second, nanoseconds, tz_hour_offset, tz_minute_offset)
1641+
tup = struct.unpack("<6hI2h", dto_value)
1642+
return datetime(
1643+
tup[0],
1644+
tup[1],
1645+
tup[2],
1646+
tup[3],
1647+
tup[4],
1648+
tup[5],
1649+
tup[6] // 1000,
1650+
timezone(timedelta(hours=tup[7], minutes=tup[8])),
1651+
)
1652+
1653+
conn.add_output_converter(-155, handle_datetimeoffset)
1654+
1655+
return conn
16311656

16321657
return connect
16331658

tests/core/test_connection_config.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1557,3 +1557,129 @@ 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_pyodbc_connection_datetimeoffset_handling():
1563+
"""Test that the MSSQL pyodbc connection properly handles DATETIMEOFFSET conversion."""
1564+
from datetime import datetime, timezone, timedelta
1565+
import struct
1566+
from unittest.mock import Mock, patch
1567+
1568+
with patch("pyodbc.connect") as mock_pyodbc_connect:
1569+
# Track calls to add_output_converter
1570+
converter_calls = []
1571+
1572+
def mock_add_output_converter(sql_type, converter_func):
1573+
converter_calls.append((sql_type, converter_func))
1574+
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
1579+
1580+
config = MSSQLConnectionConfig(
1581+
host="localhost",
1582+
driver="pyodbc", # DATETIMEOFFSET handling is pyodbc-specific
1583+
check_import=False,
1584+
)
1585+
1586+
# Get the connection factory and call it
1587+
factory_with_kwargs = config._connection_factory_with_kwargs
1588+
connection = factory_with_kwargs()
1589+
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+
)
1615+
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))
1632+
1633+
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
1639+
1640+
with patch("pyodbc.connect") as mock_pyodbc_connect:
1641+
converter_calls = []
1642+
1643+
def mock_add_output_converter(sql_type, converter_func):
1644+
converter_calls.append((sql_type, converter_func))
1645+
1646+
mock_connection = Mock()
1647+
mock_connection.add_output_converter = mock_add_output_converter
1648+
mock_pyodbc_connect.return_value = mock_connection
1649+
1650+
config = MSSQLConnectionConfig(
1651+
host="localhost",
1652+
driver="pyodbc", # DATETIMEOFFSET handling is pyodbc-specific
1653+
check_import=False,
1654+
)
1655+
1656+
factory_with_kwargs = config._connection_factory_with_kwargs
1657+
connection = factory_with_kwargs()
1658+
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+
)
1680+
1681+
result = converter_func(binary_data)
1682+
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))

0 commit comments

Comments
 (0)