Skip to content

Commit 526035f

Browse files
FIX: Extract SqlTypeCode into own module, eliminate circular import workaround
1 parent d424c6f commit 526035f

11 files changed

Lines changed: 956 additions & 100 deletions

CHANGELOG.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
77
## [Unreleased]
88

99
### Added
10+
1011
- New feature: Support for macOS and Linux.
1112
- Documentation: Added API documentation in the Wiki.
13+
- Added `SqlTypeCode` class for dual-compatible type codes in `cursor.description`.
1214

1315
### Changed
16+
1417
- Improved error handling in the connection module.
18+
- Enhanced `cursor.description[i][1]` to return `SqlTypeCode` objects that compare equal to both SQL type integers and Python types, improving backwards compatibility while aligning with DB-API 2.0. Note that `SqlTypeCode` instances are intentionally unhashable; code that previously used `cursor.description[i][1]` as a dict or set key should use `int(type_code)` or `type_code.type_code` instead.
1519

1620
### Fixed
21+
1722
- Bug fix: Resolved issue with connection timeout.
23+
- Fixed `cursor.description` type handling for better DB-API 2.0 compliance (Issue #352).
1824

1925
## [1.0.0-alpha] - 2025-02-24
2026

2127
### Added
28+
2229
- Initial release of the mssql-python driver for SQL Server.
2330

2431
### Changed
32+
2533
- N/A
2634

2735
### Fixed
28-
- N/A
36+
37+
- N/A

mssql_python/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060

6161
# Cursor Objects
6262
from .cursor import Cursor
63+
from .type_code import SqlTypeCode
6364

6465
# Logging Configuration (Simplified single-level DEBUG system)
6566
from .logging import logger, setup_logging, driver_logger

mssql_python/connection.py

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@
4545
from mssql_python.connection_string_builder import _ConnectionStringBuilder
4646
from mssql_python.constants import _RESERVED_PARAMETERS
4747

48+
from mssql_python.type_code import SqlTypeCode
49+
4850
if TYPE_CHECKING:
4951
from mssql_python.row import Row
5052

@@ -923,7 +925,9 @@ def cursor(self) -> Cursor:
923925
logger.debug("cursor: Cursor created successfully - total_cursors=%d", len(self._cursors))
924926
return cursor
925927

926-
def add_output_converter(self, sqltype: int, func: Callable[[Any], Any]) -> None:
928+
def add_output_converter(
929+
self, sqltype: Union[int, SqlTypeCode, type], func: Callable[[Any], Any]
930+
) -> None:
927931
"""
928932
Register an output converter function that will be called whenever a value
929933
with the given SQL type is read from the database.
@@ -936,32 +940,39 @@ def add_output_converter(self, sqltype: int, func: Callable[[Any], Any]) -> None
936940
vulnerabilities. This API should never be exposed to untrusted or external input.
937941
938942
Args:
939-
sqltype (int): The integer SQL type value to convert, which can be one of the
940-
defined standard constants (e.g. SQL_VARCHAR) or a database-specific
941-
value (e.g. -151 for the SQL Server 2008 geometry data type).
943+
sqltype (int, SqlTypeCode, or type): The SQL type value to convert.
944+
Also accepts SqlTypeCode objects or Python types for backward compatibility.
942945
func (callable): The converter function which will be called with a single parameter,
943946
the value, and should return the converted value. If the value is NULL
944-
then the parameter passed to the function will be None, otherwise it
945-
will be a bytes object.
947+
then the parameter passed to the function will be None. For string/binary
948+
columns, the value will be bytes (UTF-16LE encoded for strings). For other
949+
types (int, decimal.Decimal, datetime, etc.), the value will be the native
950+
Python object.
946951
947952
Returns:
948953
None
949954
"""
955+
if isinstance(sqltype, SqlTypeCode):
956+
sqltype = sqltype.type_code
950957
with self._converters_lock:
951958
self._output_converters[sqltype] = func
952959
# Pass to the underlying connection if native implementation supports it
953-
if hasattr(self._conn, "add_output_converter"):
960+
# Only forward int type codes to native layer; Python type keys are handled
961+
# only in our Python-side dictionary
962+
if isinstance(sqltype, int) and hasattr(self._conn, "add_output_converter"):
954963
self._conn.add_output_converter(sqltype, func)
955964
logger.info(f"Added output converter for SQL type {sqltype}")
956965

957-
def get_output_converter(self, sqltype: Union[int, type]) -> Optional[Callable[[Any], Any]]:
966+
def get_output_converter(
967+
self, sqltype: Union[int, SqlTypeCode, type]
968+
) -> Optional[Callable[[Any], Any]]:
958969
"""
959970
Get the output converter function for the specified SQL type.
960971
961972
Thread-safe implementation that protects the converters dictionary with a lock.
962973
963974
Args:
964-
sqltype (int or type): The SQL type value or Python type to get the converter for
975+
sqltype (int, SqlTypeCode, or type): The SQL type value to get the converter for.
965976
966977
Returns:
967978
callable or None: The converter function or None if no converter is registered
@@ -970,27 +981,43 @@ def get_output_converter(self, sqltype: Union[int, type]) -> Optional[Callable[[
970981
⚠️ The returned converter function will be executed on database values. Only use
971982
converters from trusted sources.
972983
"""
984+
original_sqltype = sqltype
985+
if isinstance(sqltype, SqlTypeCode):
986+
sqltype = sqltype.type_code
973987
with self._converters_lock:
974-
return self._output_converters.get(sqltype)
988+
result = self._output_converters.get(sqltype)
989+
# Fallback: try python_type key for backward compatibility
990+
if result is None and isinstance(original_sqltype, SqlTypeCode):
991+
result = self._output_converters.get(original_sqltype.python_type)
992+
return result
975993

976-
def remove_output_converter(self, sqltype: Union[int, type]) -> None:
994+
def remove_output_converter(self, sqltype: Union[int, SqlTypeCode, type]) -> None:
977995
"""
978996
Remove the output converter function for the specified SQL type.
979997
980998
Thread-safe implementation that protects the converters dictionary with a lock.
981999
9821000
Args:
983-
sqltype (int or type): The SQL type value to remove the converter for
1001+
sqltype (int, SqlTypeCode, or type): The SQL type value to remove the converter for.
9841002
9851003
Returns:
9861004
None
9871005
"""
1006+
python_type_key = None
1007+
if isinstance(sqltype, SqlTypeCode):
1008+
python_type_key = sqltype.python_type
1009+
sqltype = sqltype.type_code
9881010
with self._converters_lock:
9891011
if sqltype in self._output_converters:
9901012
del self._output_converters[sqltype]
9911013
# Pass to the underlying connection if native implementation supports it
992-
if hasattr(self._conn, "remove_output_converter"):
1014+
# Only forward int type codes to native layer; Python type keys are handled
1015+
# only in our Python-side dictionary
1016+
if isinstance(sqltype, int) and hasattr(self._conn, "remove_output_converter"):
9931017
self._conn.remove_output_converter(sqltype)
1018+
# Symmetric with get_output_converter: also remove python_type key if present
1019+
if python_type_key is not None and python_type_key in self._output_converters:
1020+
del self._output_converters[python_type_key]
9941021
logger.info(f"Removed output converter for SQL type {sqltype}")
9951022

9961023
def clear_output_converters(self) -> None:

mssql_python/constants.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,12 @@ class ConstantsDDBC(Enum):
114114
SQL_FETCH_ABSOLUTE = 5
115115
SQL_FETCH_RELATIVE = 6
116116
SQL_FETCH_BOOKMARK = 8
117+
# NOTE: The following SQL Server-specific type constants MUST stay in sync with
118+
# the corresponding values in mssql_python/pybind/ddbc_bindings.cpp
117119
SQL_DATETIMEOFFSET = -155
120+
SQL_SS_TIME2 = -154 # SQL Server TIME(n) type
121+
SQL_SS_UDT = -151 # SQL Server User-Defined Types (geometry, geography, hierarchyid)
122+
SQL_SS_XML = -152 # SQL Server XML type
118123
SQL_C_SS_TIMESTAMPOFFSET = 0x4001
119124
SQL_SCOPE_CURROW = 0
120125
SQL_BEST_ROWID = 1

mssql_python/cursor.py

Lines changed: 37 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from mssql_python.helpers import check_error
2121
from mssql_python.logging import logger
2222
from mssql_python import ddbc_bindings
23+
from mssql_python.type_code import SqlTypeCode
2324
from mssql_python.exceptions import (
2425
InterfaceError,
2526
NotSupportedError,
@@ -142,6 +143,9 @@ def __init__(self, connection: "Connection", timeout: int = 0) -> None:
142143
)
143144
self.messages = [] # Store diagnostic messages
144145

146+
# Store raw column metadata for converter lookups
147+
self._column_metadata = None
148+
145149
def _is_unicode_string(self, param: str) -> bool:
146150
"""
147151
Check if a string contains non-ASCII characters.
@@ -724,6 +728,14 @@ def _reset_cursor(self) -> None:
724728
logger.debug("SQLFreeHandle succeeded")
725729

726730
self._clear_rownumber()
731+
self._column_metadata = None
732+
self.description = None
733+
734+
# Clear any result-set-specific caches to avoid stale mappings
735+
if hasattr(self, "_cached_column_map"):
736+
self._cached_column_map = None
737+
if hasattr(self, "_cached_converter_map"):
738+
self._cached_converter_map = None
727739

728740
# Reinitialize the statement handle
729741
self._initialize_cursor()
@@ -756,6 +768,7 @@ def close(self) -> None:
756768
self.hstmt = None
757769
logger.debug("SQLFreeHandle succeeded")
758770
self._clear_rownumber()
771+
self._column_metadata = None # Clear metadata to prevent memory leaks
759772
self.closed = True
760773

761774
def _check_closed(self) -> None:
@@ -942,8 +955,12 @@ def _initialize_description(self, column_metadata: Optional[Any] = None) -> None
942955
"""Initialize the description attribute from column metadata."""
943956
if not column_metadata:
944957
self.description = None
958+
self._column_metadata = None # Clear metadata too
945959
return
946960

961+
# Store raw metadata for converter map building
962+
self._column_metadata = column_metadata
963+
947964
description = []
948965
for _, col in enumerate(column_metadata):
949966
# Get column name - lowercase it if the lowercase flag is set
@@ -954,10 +971,13 @@ def _initialize_description(self, column_metadata: Optional[Any] = None) -> None
954971
column_name = column_name.lower()
955972

956973
# Add to description tuple (7 elements as per PEP-249)
974+
# Use SqlTypeCode for backwards-compatible type_code that works with both
975+
# `desc[1] == str` (pandas) and `desc[1] == -9` (DB-API 2.0)
976+
sql_type = col["DataType"]
957977
description.append(
958978
(
959979
column_name, # name
960-
self._map_data_type(col["DataType"]), # type_code
980+
SqlTypeCode(sql_type), # type_code - dual compatible
961981
None, # display_size
962982
col["ColumnSize"], # internal_size
963983
col["ColumnSize"], # precision - should match ColumnSize
@@ -975,24 +995,28 @@ def _build_converter_map(self):
975995
"""
976996
if (
977997
not self.description
998+
or not self._column_metadata
978999
or not hasattr(self.connection, "_output_converters")
9791000
or not self.connection._output_converters
9801001
):
9811002
return None
9821003

9831004
converter_map = []
9841005

985-
for desc in self.description:
986-
if desc is None:
987-
converter_map.append(None)
988-
continue
989-
sql_type = desc[1]
1006+
for col_meta in self._column_metadata:
1007+
# Use the raw SQL type code from metadata, not the mapped Python type
1008+
sql_type = col_meta["DataType"]
1009+
python_type = SqlTypeCode._get_python_type(sql_type)
9901010
converter = self.connection.get_output_converter(sql_type)
991-
# If no converter found for the SQL type, try the WVARCHAR converter as a fallback
1011+
1012+
# Fallback: If no converter found for SQL type code, try the mapped Python type.
1013+
# This provides backward compatibility for code that registered converters by Python type.
9921014
if converter is None:
993-
from mssql_python.constants import ConstantsDDBC
1015+
converter = self.connection.get_output_converter(python_type)
9941016

995-
converter = self.connection.get_output_converter(ConstantsDDBC.SQL_WVARCHAR.value)
1017+
# Fallback: try SQL_WVARCHAR converter for str/bytes columns
1018+
if converter is None and python_type in (str, bytes):
1019+
converter = self.connection.get_output_converter(ddbc_sql_const.SQL_WVARCHAR.value)
9961020

9971021
converter_map.append(converter)
9981022

@@ -1022,41 +1046,6 @@ def _get_column_and_converter_maps(self):
10221046

10231047
return column_map, converter_map
10241048

1025-
def _map_data_type(self, sql_type):
1026-
"""
1027-
Map SQL data type to Python data type.
1028-
1029-
Args:
1030-
sql_type: SQL data type.
1031-
1032-
Returns:
1033-
Corresponding Python data type.
1034-
"""
1035-
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,
1039-
ddbc_sql_const.SQL_CHAR.value: str,
1040-
ddbc_sql_const.SQL_WCHAR.value: str,
1041-
ddbc_sql_const.SQL_FLOAT.value: float,
1042-
ddbc_sql_const.SQL_DOUBLE.value: float,
1043-
ddbc_sql_const.SQL_DECIMAL.value: decimal.Decimal,
1044-
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,
1048-
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,
1052-
ddbc_sql_const.SQL_BINARY.value: bytes,
1053-
ddbc_sql_const.SQL_VARBINARY.value: bytes,
1054-
ddbc_sql_const.SQL_LONGVARBINARY.value: bytes,
1055-
ddbc_sql_const.SQL_GUID.value: uuid.UUID,
1056-
# Add more mappings as needed
1057-
}
1058-
return sql_to_python_type.get(sql_type, str)
1059-
10601049
@property
10611050
def rownumber(self) -> int:
10621051
"""
@@ -1369,6 +1358,7 @@ def execute( # pylint: disable=too-many-locals,too-many-branches,too-many-state
13691358
except Exception as e: # pylint: disable=broad-exception-caught
13701359
# If describe fails, it's likely there are no results (e.g., for INSERT)
13711360
self.description = None
1361+
self._column_metadata = None
13721362

13731363
# Reset rownumber for new result set (only for SELECT statements)
13741364
if self.description: # If we have column descriptions, it's likely a SELECT
@@ -1385,15 +1375,6 @@ def execute( # pylint: disable=too-many-locals,too-many-branches,too-many-state
13851375
self._cached_column_map = None
13861376
self._cached_converter_map = None
13871377

1388-
# After successful execution, initialize description if there are results
1389-
column_metadata = []
1390-
try:
1391-
ddbc_bindings.DDBCSQLDescribeCol(self.hstmt, column_metadata)
1392-
self._initialize_description(column_metadata)
1393-
except Exception as e:
1394-
# If describe fails, it's likely there are no results (e.g., for INSERT)
1395-
self.description = None
1396-
13971378
self._reset_inputsizes() # Reset input sizes after execution
13981379
# Return self for method chaining
13991380
return self
@@ -2425,6 +2406,7 @@ def nextset(self) -> Union[bool, None]:
24252406
logger.debug("nextset: No more result sets available")
24262407
self._clear_rownumber()
24272408
self.description = None
2409+
self._column_metadata = None
24282410
return False
24292411

24302412
self._reset_rownumber()
@@ -2444,6 +2426,7 @@ def nextset(self) -> Union[bool, None]:
24442426
except Exception as e: # pylint: disable=broad-exception-caught
24452427
# If describe fails, there might be no results in this result set
24462428
self.description = None
2429+
self._column_metadata = None
24472430

24482431
logger.debug(
24492432
"nextset: Moved to next result set - column_count=%d",
@@ -2788,12 +2771,7 @@ def rollback(self):
27882771
self._connection.rollback()
27892772

27902773
def __del__(self):
2791-
"""
2792-
Destructor to ensure the cursor is closed when it is no longer needed.
2793-
This is a safety net to ensure resources are cleaned up
2794-
even if close() was not called explicitly.
2795-
If the cursor is already closed, it will not raise an exception during cleanup.
2796-
"""
2774+
"""Safety net to close cursor if close() was not called explicitly."""
27972775
if "closed" not in self.__dict__ or not self.closed:
27982776
try:
27992777
self.close()

0 commit comments

Comments
 (0)