2020from mssql_python .helpers import check_error
2121from mssql_python .logging import logger
2222from mssql_python import ddbc_bindings
23+ from mssql_python .type_code import SqlTypeCode
2324from 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