Skip to content

Commit 21705fe

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 21705fe

3 files changed

Lines changed: 306 additions & 12 deletions

File tree

mssql_python/constants.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@ 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
118120
SQL_C_SS_TIMESTAMPOFFSET = 0x4001
119121
SQL_SCOPE_CURROW = 0
120122
SQL_BEST_ROWID = 1
@@ -362,6 +364,11 @@ def get_valid_types(cls) -> set:
362364
ConstantsDDBC.SQL_DATE.value,
363365
ConstantsDDBC.SQL_TIME.value,
364366
ConstantsDDBC.SQL_TIMESTAMP.value,
367+
ConstantsDDBC.SQL_TYPE_DATE.value,
368+
ConstantsDDBC.SQL_TYPE_TIME.value,
369+
ConstantsDDBC.SQL_TYPE_TIMESTAMP.value,
370+
ConstantsDDBC.SQL_SS_TIME2.value,
371+
ConstantsDDBC.SQL_DATETIMEOFFSET.value,
365372
ConstantsDDBC.SQL_GUID.value,
366373
}
367374

mssql_python/cursor.py

Lines changed: 57 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,9 +865,19 @@ 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,
878+
# Other types
879+
ddbc_sql_const.SQL_GUID.value: ddbc_sql_const.SQL_C_GUID.value,
880+
ddbc_sql_const.SQL_SS_XML.value: ddbc_sql_const.SQL_C_WCHAR.value,
871881
}
872882
return sql_to_c_type.get(sql_type, ddbc_sql_const.SQL_C_DEFAULT.value)
873883

@@ -1026,34 +1036,69 @@ def _map_data_type(self, sql_type):
10261036
"""
10271037
Map SQL data type to Python data type.
10281038
1039+
Maps the ODBC SQL type code returned by SQLDescribeCol to the
1040+
corresponding Python type for cursor.description[i][1].
1041+
1042+
The ODBC 18 driver for SQL Server reports these type codes:
1043+
Standard ODBC 3.x types:
1044+
SQL_CHAR(1), SQL_VARCHAR(12), SQL_LONGVARCHAR(-1),
1045+
SQL_WCHAR(-8), SQL_WVARCHAR(-9), SQL_WLONGVARCHAR(-10),
1046+
SQL_INTEGER(4), SQL_SMALLINT(5), SQL_TINYINT(-6), SQL_BIGINT(-5),
1047+
SQL_BIT(-7), SQL_FLOAT(6), SQL_REAL(7), SQL_DOUBLE(8),
1048+
SQL_DECIMAL(3), SQL_NUMERIC(2),
1049+
SQL_BINARY(-2), SQL_VARBINARY(-3), SQL_LONGVARBINARY(-4),
1050+
SQL_TYPE_DATE(91), SQL_TYPE_TIME(92), SQL_TYPE_TIMESTAMP(93), SQL_GUID(-11)
1051+
SQL Server-specific types (from msodbcsql.h):
1052+
SQL_SS_TIME2(-154) for time columns
1053+
SQL_DATETIMEOFFSET(-155) for datetimeoffset columns
1054+
SQL_SS_XML(-152) for xml columns
1055+
10291056
Args:
1030-
sql_type: SQL data type.
1057+
sql_type: SQL data type code from SQLDescribeCol.
10311058
10321059
Returns:
10331060
Corresponding Python data type.
10341061
"""
10351062
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,
1063+
# String types
10391064
ddbc_sql_const.SQL_CHAR.value: str,
1065+
ddbc_sql_const.SQL_VARCHAR.value: str,
1066+
ddbc_sql_const.SQL_LONGVARCHAR.value: str,
10401067
ddbc_sql_const.SQL_WCHAR.value: str,
1068+
ddbc_sql_const.SQL_WVARCHAR.value: str,
1069+
ddbc_sql_const.SQL_WLONGVARCHAR.value: str,
1070+
# Integer types
1071+
ddbc_sql_const.SQL_INTEGER.value: int,
1072+
ddbc_sql_const.SQL_SMALLINT.value: int,
1073+
ddbc_sql_const.SQL_TINYINT.value: int,
1074+
ddbc_sql_const.SQL_BIGINT.value: int,
1075+
# Floating-point types
10411076
ddbc_sql_const.SQL_FLOAT.value: float,
10421077
ddbc_sql_const.SQL_DOUBLE.value: float,
1078+
ddbc_sql_const.SQL_REAL.value: float,
1079+
# Exact numeric types
10431080
ddbc_sql_const.SQL_DECIMAL.value: decimal.Decimal,
10441081
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,
1082+
# Date/time types — values the ODBC 18 driver actually reports
1083+
ddbc_sql_const.SQL_TYPE_DATE.value: datetime.date, # 91 — date
1084+
ddbc_sql_const.SQL_TYPE_TIME.value: datetime.time, # 92 — time (ODBC 3.x)
1085+
ddbc_sql_const.SQL_TYPE_TIMESTAMP.value: datetime.datetime, # 93 — datetime/datetime2/smalldatetime
1086+
ddbc_sql_const.SQL_SS_TIME2.value: datetime.time, # -154 — time
1087+
ddbc_sql_const.SQL_DATETIMEOFFSET.value: datetime.datetime, # -155 — datetimeoffset
1088+
# ODBC 2.x date/time aliases (defensive, in case any driver reports these)
1089+
ddbc_sql_const.SQL_DATE.value: datetime.date, # 9
1090+
ddbc_sql_const.SQL_TIME.value: datetime.time, # 10
1091+
ddbc_sql_const.SQL_TIMESTAMP.value: datetime.datetime, # 11
1092+
# Boolean
10481093
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,
1094+
# Binary types
10521095
ddbc_sql_const.SQL_BINARY.value: bytes,
10531096
ddbc_sql_const.SQL_VARBINARY.value: bytes,
10541097
ddbc_sql_const.SQL_LONGVARBINARY.value: bytes,
1098+
# UUID
10551099
ddbc_sql_const.SQL_GUID.value: uuid.UUID,
1056-
# Add more mappings as needed
1100+
# XML — driver reports SQL_SS_XML (-152), fetched as str
1101+
ddbc_sql_const.SQL_SS_XML.value: str,
10571102
}
10581103
return sql_to_python_type.get(sql_type, str)
10591104

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

0 commit comments

Comments
 (0)