Skip to content

Commit a7d9b8e

Browse files
Fix for issue 352
1 parent 6fd823e commit a7d9b8e

File tree

5 files changed

+584
-74
lines changed

5 files changed

+584
-74
lines changed

mssql_python/constants.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ class ConstantsDDBC(Enum):
115115
SQL_FETCH_RELATIVE = 6
116116
SQL_FETCH_BOOKMARK = 8
117117
SQL_DATETIMEOFFSET = -155
118+
SQL_SS_UDT = -151 # SQL Server User-Defined Types (geometry, geography, hierarchyid)
118119
SQL_C_SS_TIMESTAMPOFFSET = 0x4001
119120
SQL_SCOPE_CURROW = 0
120121
SQL_BEST_ROWID = 1
@@ -499,3 +500,15 @@ def get_attribute_set_timing(attribute):
499500
# internally.
500501
"packetsize": "PacketSize",
501502
}
503+
504+
def test_cursor_description(cursor):
505+
"""Test cursor description"""
506+
cursor.execute("SELECT database_id, name FROM sys.databases;")
507+
desc = cursor.description
508+
expected_description = [
509+
("database_id", 4, None, 10, 10, 0, False), # SQL_INTEGER
510+
("name", -9, None, 128, 128, 0, False), # SQL_WVARCHAR
511+
]
512+
assert len(desc) == len(expected_description), "Description length mismatch"
513+
for desc, expected in zip(desc, expected_description):
514+
assert desc == expected, f"Description mismatch: {desc} != {expected}"

mssql_python/cursor.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import datetime
1717
import warnings
1818
from typing import List, Union, Any, Optional, Tuple, Sequence, TYPE_CHECKING
19+
import xml
1920
from mssql_python.constants import ConstantsDDBC as ddbc_sql_const, SQLTypes
2021
from mssql_python.helpers import check_error
2122
from mssql_python.logging import logger
@@ -131,6 +132,9 @@ def __init__(self, connection: "Connection", timeout: int = 0) -> None:
131132
)
132133
self.messages = [] # Store diagnostic messages
133134

135+
# Store raw column metadata for converter lookups
136+
self._column_metadata = None
137+
134138
def _is_unicode_string(self, param: str) -> bool:
135139
"""
136140
Check if a string contains non-ASCII characters.
@@ -836,8 +840,12 @@ def _initialize_description(self, column_metadata: Optional[Any] = None) -> None
836840
"""Initialize the description attribute from column metadata."""
837841
if not column_metadata:
838842
self.description = None
843+
self._column_metadata = None # Clear metadata too
839844
return
840845

846+
# Store raw metadata for converter map building
847+
self._column_metadata = column_metadata
848+
841849
description = []
842850
for _, col in enumerate(column_metadata):
843851
# Get column name - lowercase it if the lowercase flag is set
@@ -851,7 +859,7 @@ def _initialize_description(self, column_metadata: Optional[Any] = None) -> None
851859
description.append(
852860
(
853861
column_name, # name
854-
self._map_data_type(col["DataType"]), # type_code
862+
col["DataType"], # type_code (SQL type integer) - CHANGED THIS LINE
855863
None, # display_size
856864
col["ColumnSize"], # internal_size
857865
col["ColumnSize"], # precision - should match ColumnSize
@@ -869,18 +877,17 @@ def _build_converter_map(self):
869877
"""
870878
if (
871879
not self.description
880+
or not self._column_metadata
872881
or not hasattr(self.connection, "_output_converters")
873882
or not self.connection._output_converters
874883
):
875884
return None
876885

877886
converter_map = []
878887

879-
for desc in self.description:
880-
if desc is None:
881-
converter_map.append(None)
882-
continue
883-
sql_type = desc[1]
888+
for col_meta in self._column_metadata:
889+
# Use the raw SQL type code from metadata, not the mapped Python type
890+
sql_type = col_meta["DataType"]
884891
converter = self.connection.get_output_converter(sql_type)
885892
# If no converter found for the SQL type, try the WVARCHAR converter as a fallback
886893
if converter is None:
@@ -947,6 +954,11 @@ def _map_data_type(self, sql_type):
947954
ddbc_sql_const.SQL_VARBINARY.value: bytes,
948955
ddbc_sql_const.SQL_LONGVARBINARY.value: bytes,
949956
ddbc_sql_const.SQL_GUID.value: uuid.UUID,
957+
ddbc_sql_const.SQL_SS_UDT.value: bytes, # UDTs mapped to bytes
958+
ddbc_sql_const.SQL_XML.value: xml, # XML mapped to str
959+
ddbc_sql_const.SQL_DATETIME2.value: datetime.datetime,
960+
ddbc_sql_const.SQL_SMALLDATETIME.value: datetime.datetime,
961+
ddbc_sql_const.SQL_DATETIMEOFFSET.value: datetime.datetime,
950962
# Add more mappings as needed
951963
}
952964
return sql_to_python_type.get(sql_type, str)
@@ -2373,7 +2385,6 @@ def __del__(self):
23732385
Destructor to ensure the cursor is closed when it is no longer needed.
23742386
This is a safety net to ensure resources are cleaned up
23752387
even if close() was not called explicitly.
2376-
If the cursor is already closed, it will not raise an exception during cleanup.
23772388
"""
23782389
if "closed" not in self.__dict__ or not self.closed:
23792390
try:

mssql_python/pybind/ddbc_bindings.cpp

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@
2727
#define MAX_DIGITS_IN_NUMERIC 64
2828
#define SQL_MAX_NUMERIC_LEN 16
2929
#define SQL_SS_XML (-152)
30+
#define SQL_SS_UDT (-151) // SQL Server User-Defined Types (geometry, geography, hierarchyid)
31+
#define SQL_DATETIME2 (42)
32+
#define SQL_SMALLDATETIME (58)
3033

3134
#define STRINGIFY_FOR_CASE(x) \
3235
case x: \
@@ -2837,6 +2840,11 @@ SQLRETURN SQLGetData_wrap(SqlHandlePtr StatementHandle, SQLUSMALLINT colCount, p
28372840
}
28382841
break;
28392842
}
2843+
case SQL_SS_UDT: {
2844+
LOG("SQLGetData: Streaming UDT (geometry/geography) for column %d", i);
2845+
row.append(FetchLobColumnData(hStmt, i, SQL_C_BINARY, false, true));
2846+
break;
2847+
}
28402848
case SQL_SS_XML: {
28412849
LOG("SQLGetData: Streaming XML for column %d", i);
28422850
row.append(FetchLobColumnData(hStmt, i, SQL_C_WCHAR, true, false));
@@ -3060,6 +3068,8 @@ SQLRETURN SQLGetData_wrap(SqlHandlePtr StatementHandle, SQLUSMALLINT colCount, p
30603068
}
30613069
case SQL_TIMESTAMP:
30623070
case SQL_TYPE_TIMESTAMP:
3071+
case SQL_DATETIME2:
3072+
case SQL_SMALLDATETIME:
30633073
case SQL_DATETIME: {
30643074
SQL_TIMESTAMP_STRUCT timestampValue;
30653075
ret = SQLGetData_ptr(hStmt, i, SQL_C_TYPE_TIMESTAMP, &timestampValue,
@@ -3643,6 +3653,8 @@ SQLRETURN FetchBatchData(SQLHSTMT hStmt, ColumnBuffers& buffers, py::list& colum
36433653
}
36443654
case SQL_TIMESTAMP:
36453655
case SQL_TYPE_TIMESTAMP:
3656+
case SQL_DATETIME2:
3657+
case SQL_SMALLDATETIME:
36463658
case SQL_DATETIME: {
36473659
const SQL_TIMESTAMP_STRUCT& ts = buffers.timestampBuffers[col - 1][i];
36483660
PyObject* datetimeObj = PythonObjectCache::get_datetime_class()(
@@ -3822,6 +3834,9 @@ size_t calculateRowSize(py::list& columnNames, SQLUSMALLINT numCols) {
38223834
case SQL_SS_TIMESTAMPOFFSET:
38233835
rowSize += sizeof(DateTimeOffset);
38243836
break;
3837+
case SQL_SS_UDT:
3838+
rowSize += columnSize; // UDT types use column size as-is
3839+
break;
38253840
default:
38263841
std::wstring columnName = columnMeta["ColumnName"].cast<std::wstring>();
38273842
std::ostringstream errorString;
@@ -3874,7 +3889,7 @@ SQLRETURN FetchMany_wrap(SqlHandlePtr StatementHandle, py::list& rows, int fetch
38743889

38753890
if ((dataType == SQL_WVARCHAR || dataType == SQL_WLONGVARCHAR || dataType == SQL_VARCHAR ||
38763891
dataType == SQL_LONGVARCHAR || dataType == SQL_VARBINARY ||
3877-
dataType == SQL_LONGVARBINARY || dataType == SQL_SS_XML) &&
3892+
dataType == SQL_LONGVARBINARY || dataType == SQL_SS_XML || dataType == SQL_SS_UDT) &&
38783893
(columnSize == 0 || columnSize == SQL_NO_TOTAL || columnSize > SQL_MAX_LOB_SIZE)) {
38793894
lobColumns.push_back(i + 1); // 1-based
38803895
}
@@ -4004,7 +4019,7 @@ SQLRETURN FetchAll_wrap(SqlHandlePtr StatementHandle, py::list& rows) {
40044019

40054020
if ((dataType == SQL_WVARCHAR || dataType == SQL_WLONGVARCHAR || dataType == SQL_VARCHAR ||
40064021
dataType == SQL_LONGVARCHAR || dataType == SQL_VARBINARY ||
4007-
dataType == SQL_LONGVARBINARY || dataType == SQL_SS_XML) &&
4022+
dataType == SQL_LONGVARBINARY || dataType == SQL_SS_XML || dataType == SQL_SS_UDT) &&
40084023
(columnSize == 0 || columnSize == SQL_NO_TOTAL || columnSize > SQL_MAX_LOB_SIZE)) {
40094024
lobColumns.push_back(i + 1); // 1-based
40104025
}

tests/test_003_connection.py

Lines changed: 20 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -4273,32 +4273,15 @@ def test_converter_integration(db_connection):
42734273
cursor = db_connection.cursor()
42744274
sql_wvarchar = ConstantsDDBC.SQL_WVARCHAR.value
42754275

4276-
# Test with string converter
4276+
# Register converter for SQL_WVARCHAR type
42774277
db_connection.add_output_converter(sql_wvarchar, custom_string_converter)
42784278

42794279
# Test a simple string query
42804280
cursor.execute("SELECT N'test string' AS test_col")
42814281
row = cursor.fetchone()
42824282

4283-
# Check if the type matches what we expect for SQL_WVARCHAR
4284-
# For Cursor.description, the second element is the type code
4285-
column_type = cursor.description[0][1]
4286-
4287-
# If the cursor description has SQL_WVARCHAR as the type code,
4288-
# then our converter should be applied
4289-
if column_type == sql_wvarchar:
4290-
assert row[0].startswith("CONVERTED:"), "Output converter not applied"
4291-
else:
4292-
# If the type code is different, adjust the test or the converter
4293-
print(f"Column type is {column_type}, not {sql_wvarchar}")
4294-
# Add converter for the actual type used
4295-
db_connection.clear_output_converters()
4296-
db_connection.add_output_converter(column_type, custom_string_converter)
4297-
4298-
# Re-execute the query
4299-
cursor.execute("SELECT N'test string' AS test_col")
4300-
row = cursor.fetchone()
4301-
assert row[0].startswith("CONVERTED:"), "Output converter not applied"
4283+
# The converter should be applied based on the SQL type code
4284+
assert row[0].startswith("CONVERTED:"), "Output converter not applied"
43024285

43034286
# Clean up
43044287
db_connection.clear_output_converters()
@@ -4385,26 +4368,23 @@ def test_multiple_output_converters(db_connection):
43854368
"""Test that multiple output converters can work together"""
43864369
cursor = db_connection.cursor()
43874370

4388-
# Execute a query to get the actual type codes used
4389-
cursor.execute("SELECT CAST(42 AS INT) as int_col, N'test' as str_col")
4390-
int_type = cursor.description[0][1] # Type code for integer column
4391-
str_type = cursor.description[1][1] # Type code for string column
4371+
# Use SQL type constants directly
4372+
sql_integer = ConstantsDDBC.SQL_INTEGER.value # SQL type code for INT
4373+
sql_wvarchar = ConstantsDDBC.SQL_WVARCHAR.value # SQL type code for NVARCHAR
43924374

43934375
# Add converter for string type
4394-
db_connection.add_output_converter(str_type, custom_string_converter)
4376+
db_connection.add_output_converter(sql_wvarchar, custom_string_converter)
43954377

43964378
# Add converter for integer type
43974379
def int_converter(value):
43984380
if value is None:
43994381
return None
4400-
# Convert from bytes to int and multiply by 2
4401-
if isinstance(value, bytes):
4402-
return int.from_bytes(value, byteorder="little") * 2
4403-
elif isinstance(value, int):
4382+
# Integers are already Python ints, so just multiply by 2
4383+
if isinstance(value, int):
44044384
return value * 2
44054385
return value
44064386

4407-
db_connection.add_output_converter(int_type, int_converter)
4387+
db_connection.add_output_converter(sql_integer, int_converter)
44084388

44094389
# Test query with both types
44104390
cursor.execute("SELECT CAST(42 AS INT) as int_col, N'test' as str_col")
@@ -4811,32 +4791,15 @@ def test_converter_integration(db_connection):
48114791
cursor = db_connection.cursor()
48124792
sql_wvarchar = ConstantsDDBC.SQL_WVARCHAR.value
48134793

4814-
# Test with string converter
4794+
# Register converter for SQL_WVARCHAR type
48154795
db_connection.add_output_converter(sql_wvarchar, custom_string_converter)
48164796

48174797
# Test a simple string query
48184798
cursor.execute("SELECT N'test string' AS test_col")
48194799
row = cursor.fetchone()
48204800

4821-
# Check if the type matches what we expect for SQL_WVARCHAR
4822-
# For Cursor.description, the second element is the type code
4823-
column_type = cursor.description[0][1]
4824-
4825-
# If the cursor description has SQL_WVARCHAR as the type code,
4826-
# then our converter should be applied
4827-
if column_type == sql_wvarchar:
4828-
assert row[0].startswith("CONVERTED:"), "Output converter not applied"
4829-
else:
4830-
# If the type code is different, adjust the test or the converter
4831-
print(f"Column type is {column_type}, not {sql_wvarchar}")
4832-
# Add converter for the actual type used
4833-
db_connection.clear_output_converters()
4834-
db_connection.add_output_converter(column_type, custom_string_converter)
4835-
4836-
# Re-execute the query
4837-
cursor.execute("SELECT N'test string' AS test_col")
4838-
row = cursor.fetchone()
4839-
assert row[0].startswith("CONVERTED:"), "Output converter not applied"
4801+
# The converter should be applied based on the SQL type code
4802+
assert row[0].startswith("CONVERTED:"), "Output converter not applied"
48404803

48414804
# Clean up
48424805
db_connection.clear_output_converters()
@@ -4923,26 +4886,23 @@ def test_multiple_output_converters(db_connection):
49234886
"""Test that multiple output converters can work together"""
49244887
cursor = db_connection.cursor()
49254888

4926-
# Execute a query to get the actual type codes used
4927-
cursor.execute("SELECT CAST(42 AS INT) as int_col, N'test' as str_col")
4928-
int_type = cursor.description[0][1] # Type code for integer column
4929-
str_type = cursor.description[1][1] # Type code for string column
4889+
# Use SQL type constants directly
4890+
sql_integer = ConstantsDDBC.SQL_INTEGER.value # SQL type code for INT
4891+
sql_wvarchar = ConstantsDDBC.SQL_WVARCHAR.value # SQL type code for NVARCHAR
49304892

49314893
# Add converter for string type
4932-
db_connection.add_output_converter(str_type, custom_string_converter)
4894+
db_connection.add_output_converter(sql_wvarchar, custom_string_converter)
49334895

49344896
# Add converter for integer type
49354897
def int_converter(value):
49364898
if value is None:
49374899
return None
4938-
# Convert from bytes to int and multiply by 2
4939-
if isinstance(value, bytes):
4940-
return int.from_bytes(value, byteorder="little") * 2
4941-
elif isinstance(value, int):
4900+
# Integers are already Python ints, so just multiply by 2
4901+
if isinstance(value, int):
49424902
return value * 2
49434903
return value
49444904

4945-
db_connection.add_output_converter(int_type, int_converter)
4905+
db_connection.add_output_converter(sql_integer, int_converter)
49464906

49474907
# Test query with both types
49484908
cursor.execute("SELECT CAST(42 AS INT) as int_col, N'test' as str_col")

0 commit comments

Comments
 (0)