Skip to content

Commit 3bf27c3

Browse files
FIX: Align type code mappings with ODBC 18 driver source (#352)
Root cause: _map_data_type only had ODBC 2.x constants (SQL_DATE=9, SQL_TIME=10, SQL_TIMESTAMP=11) but the ODBC 18 driver reports ODBC 3.x codes via SQLDescribeCol. Date columns returned SQL_TYPE_DATE(91) which fell through to str default, causing polars ComputeError. Verified against ODBC 18 driver source (sqlcmisc.cpp rgbSRV2SQLTYPE[] and sqlcdesc.cpp SQL_DESC_CONCISE_TYPE handling): constants.py: - Add SQL_SS_TIME2(-154), SQL_SS_XML(-152), SQL_C_SS_TIME2(0x4000) cursor.py _map_data_type: - Replace ODBC 2.x entries with driver-verified ODBC 3.x codes - SQL_TYPE_DATE(91) -> datetime.date - SQL_TYPE_TIMESTAMP(93) -> datetime.datetime - SQL_SS_TIME2(-154) -> datetime.time - SQL_DATETIMEOFFSET(-155) -> datetime.datetime - SQL_SS_XML(-152) -> str - Add missing types: SQL_LONGVARCHAR, SQL_WLONGVARCHAR, SQL_REAL cursor.py _get_c_type_for_sql_type: - SQL_TYPE_DATE -> SQL_C_TYPE_DATE (was SQL_DATE) - SQL_SS_TIME2 -> SQL_C_SS_TIME2 (was SQL_TIME) - SQL_TYPE_TIMESTAMP -> SQL_C_TYPE_TIMESTAMP (was SQL_TIMESTAMP) - Add SQL_DATETIMEOFFSET -> SQL_C_SS_TIMESTAMPOFFSET Tests: - 14 tests: cursor.description type_code verification (6 date/time types + isclass check), polars integration (4), pandas integration (3) Closes #352
1 parent d424c6f commit 3bf27c3

3 files changed

Lines changed: 302 additions & 12 deletions

File tree

mssql_python/constants.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,9 @@ class ConstantsDDBC(Enum):
115115
SQL_FETCH_RELATIVE = 6
116116
SQL_FETCH_BOOKMARK = 8
117117
SQL_DATETIMEOFFSET = -155
118+
SQL_SS_TIME2 = -154
119+
SQL_SS_XML = -152
120+
SQL_C_SS_TIME2 = 0x4000
118121
SQL_C_SS_TIMESTAMPOFFSET = 0x4001
119122
SQL_SCOPE_CURROW = 0
120123
SQL_BEST_ROWID = 1
@@ -362,6 +365,11 @@ def get_valid_types(cls) -> set:
362365
ConstantsDDBC.SQL_DATE.value,
363366
ConstantsDDBC.SQL_TIME.value,
364367
ConstantsDDBC.SQL_TIMESTAMP.value,
368+
ConstantsDDBC.SQL_TYPE_DATE.value,
369+
ConstantsDDBC.SQL_TYPE_TIME.value,
370+
ConstantsDDBC.SQL_TYPE_TIMESTAMP.value,
371+
ConstantsDDBC.SQL_SS_TIME2.value,
372+
ConstantsDDBC.SQL_DATETIMEOFFSET.value,
365373
ConstantsDDBC.SQL_GUID.value,
366374
}
367375

mssql_python/cursor.py

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -844,7 +844,7 @@ def _reset_inputsizes(self) -> None:
844844
self._inputsizes = None
845845

846846
def _get_c_type_for_sql_type(self, sql_type: int) -> int:
847-
"""Map SQL type to appropriate C type for parameter binding"""
847+
"""Map SQL type to appropriate C type for parameter binding."""
848848
sql_to_c_type = {
849849
ddbc_sql_const.SQL_CHAR.value: ddbc_sql_const.SQL_C_CHAR.value,
850850
ddbc_sql_const.SQL_VARCHAR.value: ddbc_sql_const.SQL_C_CHAR.value,
@@ -865,6 +865,13 @@ def _get_c_type_for_sql_type(self, sql_type: int) -> int:
865865
ddbc_sql_const.SQL_BINARY.value: ddbc_sql_const.SQL_C_BINARY.value,
866866
ddbc_sql_const.SQL_VARBINARY.value: ddbc_sql_const.SQL_C_BINARY.value,
867867
ddbc_sql_const.SQL_LONGVARBINARY.value: ddbc_sql_const.SQL_C_BINARY.value,
868+
# ODBC 3.x date/time types (reported by ODBC 18 driver)
869+
ddbc_sql_const.SQL_TYPE_DATE.value: ddbc_sql_const.SQL_C_TYPE_DATE.value,
870+
ddbc_sql_const.SQL_TYPE_TIME.value: ddbc_sql_const.SQL_C_TYPE_TIME.value,
871+
ddbc_sql_const.SQL_TYPE_TIMESTAMP.value: ddbc_sql_const.SQL_C_TYPE_TIMESTAMP.value,
872+
ddbc_sql_const.SQL_SS_TIME2.value: ddbc_sql_const.SQL_C_TYPE_TIME.value,
873+
ddbc_sql_const.SQL_DATETIMEOFFSET.value: ddbc_sql_const.SQL_C_SS_TIMESTAMPOFFSET.value,
874+
# ODBC 2.x aliases (accepted by setinputsizes via SQLTypes)
868875
ddbc_sql_const.SQL_DATE.value: ddbc_sql_const.SQL_C_TYPE_DATE.value,
869876
ddbc_sql_const.SQL_TIME.value: ddbc_sql_const.SQL_C_TYPE_TIME.value,
870877
ddbc_sql_const.SQL_TIMESTAMP.value: ddbc_sql_const.SQL_C_TYPE_TIMESTAMP.value,
@@ -1026,34 +1033,69 @@ def _map_data_type(self, sql_type):
10261033
"""
10271034
Map SQL data type to Python data type.
10281035
1036+
Maps the ODBC SQL type code returned by SQLDescribeCol to the
1037+
corresponding Python type for cursor.description[i][1].
1038+
1039+
The ODBC 18 driver for SQL Server reports these type codes:
1040+
Standard ODBC 3.x types:
1041+
SQL_CHAR(1), SQL_VARCHAR(12), SQL_LONGVARCHAR(-1),
1042+
SQL_WCHAR(-8), SQL_WVARCHAR(-9), SQL_WLONGVARCHAR(-10),
1043+
SQL_INTEGER(4), SQL_SMALLINT(5), SQL_TINYINT(-6), SQL_BIGINT(-5),
1044+
SQL_BIT(-7), SQL_FLOAT(6), SQL_REAL(7), SQL_DOUBLE(8),
1045+
SQL_DECIMAL(3), SQL_NUMERIC(2),
1046+
SQL_BINARY(-2), SQL_VARBINARY(-3), SQL_LONGVARBINARY(-4),
1047+
SQL_TYPE_DATE(91), SQL_TYPE_TIMESTAMP(93), SQL_GUID(-11)
1048+
SQL Server-specific types (from msodbcsql.h):
1049+
SQL_SS_TIME2(-154) for time columns
1050+
SQL_DATETIMEOFFSET(-155) for datetimeoffset columns
1051+
SQL_SS_XML(-152) for xml columns
1052+
10291053
Args:
1030-
sql_type: SQL data type.
1054+
sql_type: SQL data type code from SQLDescribeCol.
10311055
10321056
Returns:
10331057
Corresponding Python data type.
10341058
"""
10351059
sql_to_python_type = {
1036-
ddbc_sql_const.SQL_INTEGER.value: int,
1037-
ddbc_sql_const.SQL_VARCHAR.value: str,
1038-
ddbc_sql_const.SQL_WVARCHAR.value: str,
1060+
# String types
10391061
ddbc_sql_const.SQL_CHAR.value: str,
1062+
ddbc_sql_const.SQL_VARCHAR.value: str,
1063+
ddbc_sql_const.SQL_LONGVARCHAR.value: str,
10401064
ddbc_sql_const.SQL_WCHAR.value: str,
1065+
ddbc_sql_const.SQL_WVARCHAR.value: str,
1066+
ddbc_sql_const.SQL_WLONGVARCHAR.value: str,
1067+
# Integer types
1068+
ddbc_sql_const.SQL_INTEGER.value: int,
1069+
ddbc_sql_const.SQL_SMALLINT.value: int,
1070+
ddbc_sql_const.SQL_TINYINT.value: int,
1071+
ddbc_sql_const.SQL_BIGINT.value: int,
1072+
# Floating-point types
10411073
ddbc_sql_const.SQL_FLOAT.value: float,
10421074
ddbc_sql_const.SQL_DOUBLE.value: float,
1075+
ddbc_sql_const.SQL_REAL.value: float,
1076+
# Exact numeric types
10431077
ddbc_sql_const.SQL_DECIMAL.value: decimal.Decimal,
10441078
ddbc_sql_const.SQL_NUMERIC.value: decimal.Decimal,
1045-
ddbc_sql_const.SQL_DATE.value: datetime.date,
1046-
ddbc_sql_const.SQL_TIMESTAMP.value: datetime.datetime,
1047-
ddbc_sql_const.SQL_TIME.value: datetime.time,
1079+
# Date/time types — values the ODBC 18 driver actually reports
1080+
ddbc_sql_const.SQL_TYPE_DATE.value: datetime.date, # 91 — date
1081+
ddbc_sql_const.SQL_TYPE_TIME.value: datetime.time, # 92 — time (ODBC 3.x)
1082+
ddbc_sql_const.SQL_TYPE_TIMESTAMP.value: datetime.datetime, # 93 — datetime/datetime2/smalldatetime
1083+
ddbc_sql_const.SQL_SS_TIME2.value: datetime.time, # -154 — time
1084+
ddbc_sql_const.SQL_DATETIMEOFFSET.value: datetime.datetime, # -155 — datetimeoffset
1085+
# ODBC 2.x date/time aliases (defensive, in case any driver reports these)
1086+
ddbc_sql_const.SQL_DATE.value: datetime.date, # 9
1087+
ddbc_sql_const.SQL_TIME.value: datetime.time, # 10
1088+
ddbc_sql_const.SQL_TIMESTAMP.value: datetime.datetime, # 11
1089+
# Boolean
10481090
ddbc_sql_const.SQL_BIT.value: bool,
1049-
ddbc_sql_const.SQL_TINYINT.value: int,
1050-
ddbc_sql_const.SQL_SMALLINT.value: int,
1051-
ddbc_sql_const.SQL_BIGINT.value: int,
1091+
# Binary types
10521092
ddbc_sql_const.SQL_BINARY.value: bytes,
10531093
ddbc_sql_const.SQL_VARBINARY.value: bytes,
10541094
ddbc_sql_const.SQL_LONGVARBINARY.value: bytes,
1095+
# UUID
10551096
ddbc_sql_const.SQL_GUID.value: uuid.UUID,
1056-
# Add more mappings as needed
1097+
# XML — driver reports SQL_SS_XML (-152), fetched as str
1098+
ddbc_sql_const.SQL_SS_XML.value: str,
10571099
}
10581100
return sql_to_python_type.get(sql_type, str)
10591101

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
"""Tests that polars and pandas correctly infer schemas from cursor.description type codes."""
2+
3+
import datetime
4+
import inspect
5+
import pytest
6+
7+
import mssql_python
8+
9+
try:
10+
import polars as pl
11+
12+
HAS_POLARS = True
13+
except ImportError:
14+
HAS_POLARS = False
15+
16+
try:
17+
import pandas as pd
18+
19+
HAS_PANDAS = True
20+
except ImportError:
21+
HAS_PANDAS = False
22+
23+
24+
# ── cursor.description type_code verification ─────────────────────────────
25+
26+
27+
class TestCursorDescriptionTypeCodes:
28+
"""Verify cursor.description returns isclass-compatible Python types."""
29+
30+
def test_date_type_code_is_datetime_date(self, cursor):
31+
"""DATE columns must report datetime.date, not str."""
32+
cursor.execute("SELECT CAST('2024-01-15' AS DATE) AS d")
33+
type_code = cursor.description[0][1]
34+
assert type_code is datetime.date
35+
assert inspect.isclass(type_code)
36+
cursor.fetchall()
37+
38+
def test_time_type_code_is_datetime_time(self, cursor):
39+
"""TIME columns must report datetime.time."""
40+
cursor.execute("SELECT CAST('13:45:30' AS TIME) AS t")
41+
type_code = cursor.description[0][1]
42+
assert type_code is datetime.time
43+
assert inspect.isclass(type_code)
44+
cursor.fetchall()
45+
46+
def test_datetime_type_code_is_datetime_datetime(self, cursor):
47+
"""DATETIME columns must report datetime.datetime."""
48+
cursor.execute("SELECT CAST('2024-01-15 13:45:30' AS DATETIME) AS dt")
49+
type_code = cursor.description[0][1]
50+
assert type_code is datetime.datetime
51+
assert inspect.isclass(type_code)
52+
cursor.fetchall()
53+
54+
def test_datetime2_type_code_is_datetime_datetime(self, cursor):
55+
"""DATETIME2 columns must report datetime.datetime."""
56+
cursor.execute("SELECT CAST('2024-01-15 13:45:30.1234567' AS DATETIME2) AS dt2")
57+
type_code = cursor.description[0][1]
58+
assert type_code is datetime.datetime
59+
assert inspect.isclass(type_code)
60+
cursor.fetchall()
61+
62+
def test_smalldatetime_type_code_is_datetime_datetime(self, cursor):
63+
"""SMALLDATETIME columns must report datetime.datetime."""
64+
cursor.execute("SELECT CAST('2024-01-15 13:45:00' AS SMALLDATETIME) AS sdt")
65+
type_code = cursor.description[0][1]
66+
assert type_code is datetime.datetime
67+
assert inspect.isclass(type_code)
68+
cursor.fetchall()
69+
70+
def test_datetimeoffset_type_code_is_datetime_datetime(self, cursor):
71+
"""DATETIMEOFFSET columns must report datetime.datetime."""
72+
cursor.execute("SELECT CAST('2024-01-15 13:45:30.123 +05:30' AS DATETIMEOFFSET) AS dto")
73+
type_code = cursor.description[0][1]
74+
assert type_code is datetime.datetime
75+
assert inspect.isclass(type_code)
76+
cursor.fetchall()
77+
78+
def test_all_types_are_isclass(self, cursor):
79+
"""Every type_code in cursor.description must pass inspect.isclass()."""
80+
cursor.execute("""
81+
SELECT
82+
CAST(1 AS INT) AS i,
83+
CAST('x' AS NVARCHAR(10)) AS s,
84+
CAST('2024-01-15' AS DATE) AS d,
85+
CAST('13:45:30' AS TIME) AS t,
86+
CAST('2024-01-15 13:45:30' AS DATETIME2) AS dt2,
87+
CAST(1.5 AS DECIMAL(10,2)) AS dec,
88+
CAST(1 AS BIT) AS b,
89+
CAST(0x01 AS VARBINARY(10)) AS bin
90+
""")
91+
for desc in cursor.description:
92+
col_name = desc[0]
93+
type_code = desc[1]
94+
assert inspect.isclass(
95+
type_code
96+
), f"Column '{col_name}': type_code={type_code!r} fails isclass()"
97+
cursor.fetchall()
98+
99+
100+
# ── Polars integration ────────────────────────────────────────────────────
101+
102+
103+
@pytest.mark.skipif(not HAS_POLARS, reason="polars not installed")
104+
class TestPolarsIntegration:
105+
"""Polars read_database must infer correct dtypes from cursor.description."""
106+
107+
def test_polars_date_column(self, db_connection):
108+
"""Issue #352: DATE columns caused ComputeError in polars."""
109+
df = pl.read_database(
110+
query="SELECT CAST('2024-01-15' AS DATE) AS d",
111+
connection=db_connection,
112+
)
113+
assert df.schema["d"] == pl.Date
114+
assert df["d"][0] == datetime.date(2024, 1, 15)
115+
116+
def test_polars_all_datetime_types(self, db_connection):
117+
"""All date/time types must produce correct polars dtypes."""
118+
df = pl.read_database(
119+
query="""
120+
SELECT
121+
CAST('2024-01-15' AS DATE) AS d,
122+
CAST('2024-01-15 13:45:30' AS DATETIME) AS dt,
123+
CAST('2024-01-15 13:45:30.123' AS DATETIME2) AS dt2,
124+
CAST('2024-01-15 13:45:00' AS SMALLDATETIME) AS sdt
125+
""",
126+
connection=db_connection,
127+
)
128+
assert df.schema["d"] == pl.Date
129+
assert df.schema["dt"] == pl.Datetime
130+
assert df.schema["dt2"] == pl.Datetime
131+
assert df.schema["sdt"] == pl.Datetime
132+
133+
def test_polars_mixed_types(self, db_connection):
134+
"""Mixed column types with DATE must not cause schema mismatch."""
135+
df = pl.read_database(
136+
query="""
137+
SELECT
138+
CAST(42 AS INT) AS i,
139+
CAST('hello' AS NVARCHAR(50)) AS s,
140+
CAST('2024-06-15' AS DATE) AS d,
141+
CAST(99.95 AS DECIMAL(10,2)) AS amount
142+
""",
143+
connection=db_connection,
144+
)
145+
assert df["i"][0] == 42
146+
assert df["s"][0] == "hello"
147+
assert df["d"][0] == datetime.date(2024, 6, 15)
148+
assert df.schema["d"] == pl.Date
149+
150+
def test_polars_date_with_nulls(self, db_connection):
151+
"""DATE columns with NULLs must still infer Date dtype."""
152+
cursor = db_connection.cursor()
153+
try:
154+
cursor.execute("""
155+
CREATE TABLE #polars_null_test (
156+
id INT,
157+
d DATE
158+
)
159+
""")
160+
cursor.execute("""
161+
INSERT INTO #polars_null_test VALUES
162+
(1, '2024-01-15'),
163+
(2, NULL),
164+
(3, '2024-03-20')
165+
""")
166+
db_connection.commit()
167+
168+
df = pl.read_database(
169+
query="SELECT * FROM #polars_null_test ORDER BY id",
170+
connection=db_connection,
171+
)
172+
assert df.schema["d"] == pl.Date
173+
assert df["d"][0] == datetime.date(2024, 1, 15)
174+
assert df["d"][1] is None
175+
assert df["d"][2] == datetime.date(2024, 3, 20)
176+
finally:
177+
try:
178+
cursor.execute("DROP TABLE IF EXISTS #polars_null_test")
179+
db_connection.commit()
180+
except Exception:
181+
pass
182+
cursor.close()
183+
184+
185+
# ── Pandas integration ────────────────────────────────────────────────────
186+
187+
188+
@pytest.mark.skipif(not HAS_PANDAS, reason="pandas not installed")
189+
@pytest.mark.filterwarnings("ignore::UserWarning")
190+
class TestPandasIntegration:
191+
"""Pandas read_sql must handle date/time columns correctly."""
192+
193+
def test_pandas_date_column(self, db_connection):
194+
"""DATE columns must be readable by pandas without error."""
195+
df = pd.read_sql(
196+
"SELECT CAST('2024-01-15' AS DATE) AS d",
197+
db_connection,
198+
)
199+
assert len(df) == 1
200+
val = df["d"].iloc[0]
201+
# pandas may return datetime or date depending on version
202+
if isinstance(val, datetime.datetime):
203+
assert val.date() == datetime.date(2024, 1, 15)
204+
else:
205+
assert val == datetime.date(2024, 1, 15)
206+
207+
def test_pandas_all_datetime_types(self, db_connection):
208+
"""All date/time types must be readable by pandas."""
209+
df = pd.read_sql(
210+
"""
211+
SELECT
212+
CAST('2024-01-15' AS DATE) AS d,
213+
CAST('2024-01-15 13:45:30' AS DATETIME) AS dt,
214+
CAST('2024-01-15 13:45:30.123' AS DATETIME2) AS dt2,
215+
CAST('2024-01-15 13:45:00' AS SMALLDATETIME) AS sdt
216+
""",
217+
db_connection,
218+
)
219+
assert len(df) == 1
220+
assert len(df.columns) == 4
221+
222+
def test_pandas_mixed_types_with_date(self, db_connection):
223+
"""Mixed column types including DATE must work correctly."""
224+
df = pd.read_sql(
225+
"""
226+
SELECT
227+
CAST(42 AS INT) AS i,
228+
CAST('hello' AS NVARCHAR(50)) AS s,
229+
CAST('2024-06-15' AS DATE) AS d,
230+
CAST(99.95 AS DECIMAL(10,2)) AS amount
231+
""",
232+
db_connection,
233+
)
234+
assert df["i"].iloc[0] == 42
235+
assert df["s"].iloc[0] == "hello"
236+
val = df["d"].iloc[0]
237+
if isinstance(val, datetime.datetime):
238+
assert val.date() == datetime.date(2024, 6, 15)
239+
else:
240+
assert val == datetime.date(2024, 6, 15)

0 commit comments

Comments
 (0)