Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 29 additions & 8 deletions mssql_python/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -1212,7 +1212,7 @@ def cursor(self) -> Cursor:
logger.debug("cursor: Cursor created successfully - total_cursors=%d", len(self._cursors))
return cursor

def add_output_converter(self, sqltype: int, func: Callable[[Any], Any]) -> None:
def add_output_converter(self, sqltype: Union[int, type], func: Callable[[Any], Any]) -> None:

@subrata-ms subrata-ms Jul 30, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like Union[int, type] Is Very Broad. It seems we are now accepting any python type like list ,dict ,tuple ,set, object, ect.
Are all types actually supported?
What happens when user call/implement : add_output_converter(set, func) ?
is it the API is exposing more than it can realistically support?
Please check.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The broad annotation is intentional (pyodbc-compatible). Keys are only stored, then dispatched by exact match against a column's integer ODBC SQL code or its materialized Python type. So add_output_converter(set, func) (or list/dict/object/a Decimal subclass) is a harmless no-op - stored but never invoked, no crash. Documented in the docstring and proven by test_output_converter_unsupported_keys_are_noops_gh684.

"""
Register an output converter function that will be called whenever a value
with the given SQL type is read from the database.
Expand All @@ -1225,13 +1225,34 @@ def add_output_converter(self, sqltype: int, func: Callable[[Any], Any]) -> None
vulnerabilities. This API should never be exposed to untrusted or external input.

Args:
sqltype (int): The integer SQL type value to convert, which can be one of the
defined standard constants (e.g. SQL_VARCHAR) or a database-specific
value (e.g. -151 for the SQL Server 2008 geometry data type).
func (callable): The converter function which will be called with a single parameter,
the value, and should return the converted value. If the value is NULL
then the parameter passed to the function will be None, otherwise it
will be a bytes object.
sqltype (int or type): The type to convert. Either:
- an integer ODBC SQL type code (pyodbc-compatible), which can be one of
the standard constants (e.g. SQL_VARCHAR, SQL_DECIMAL) or a
database-specific value (e.g. -151 for the SQL Server geometry type). The
converter fires for columns whose ODBC SQL type matches exactly, so
distinct types such as DECIMAL and NUMERIC can have separate converters; or
- a Python type (e.g. ``decimal.Decimal``, ``str``, ``bytes``), which fires
for every column whose value materializes to that Python type (so, for
example, a single ``decimal.Decimal`` converter matches DECIMAL, NUMERIC,
MONEY and SMALLMONEY columns alike). The supported Python types are
``str``, ``bytes``, ``bool``, ``int``, ``float``, ``decimal.Decimal``,
``datetime.date``, ``datetime.time``, ``datetime.datetime`` and
``uuid.UUID``.
When both an integer-keyed and a Python-type-keyed converter could apply to
the same column, the integer SQL-type converter takes precedence.

For pyodbc compatibility the ``sqltype`` argument is not validated: any key
is accepted and stored. A key that matches neither an exact integer ODBC SQL
type code nor an exact materialized Python type (for example a ``set``, a
``dict``, ``object``, or a ``decimal.Decimal`` subclass) is stored but never
dispatched — registering it is a harmless no-op rather than an error.
func (callable): The converter function, called with a single parameter (the
value) that returns the converted value. The converter is not
invoked for SQL NULL values (they are returned as ``None``
unchanged). For non-NULL values the parameter is the value
already materialized as its Python type (e.g. a
``decimal.Decimal`` or ``datetime.datetime``); string values are
passed as their UTF-16LE-encoded ``bytes``.

Returns:
None
Expand Down
34 changes: 30 additions & 4 deletions mssql_python/cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,10 @@ def __init__(self, connection: "Connection", timeout: int = 0) -> None:
self._cached_column_map = None
self._cached_column_map_lower = None
self._cached_converter_map = None
# Raw ODBC SQL type codes (from SQLDescribeCol) per column, parallel to
# self.description. Kept so output-converter dispatch can key on the integer
# ODBC SQL type code (pyodbc-compatible), not just the mapped Python type. See #684.
self._column_sql_types = None
self._uuid_str_indices = None # Pre-computed UUID column indices for str conversion
# Cache the effective native_uuid setting for this cursor's connection.
# Resolution order: connection._native_uuid (if not None) → module-level setting.
Expand Down Expand Up @@ -1078,9 +1082,13 @@ def _initialize_description(self, column_metadata: Optional[Any] = None) -> None
"""Initialize the description attribute from column metadata."""
if not column_metadata:
self.description = None
self._column_sql_types = None
return

description = []
# Raw ODBC SQL type codes, parallel to description, for output-converter
# dispatch by integer SQL type (see _build_converter_map / #684).
sql_type_codes = []
for _, col in enumerate(column_metadata):
# Get column name - lowercase it if the lowercase flag is set
column_name = col["ColumnName"]
Expand All @@ -1089,6 +1097,7 @@ def _initialize_description(self, column_metadata: Optional[Any] = None) -> None
if get_settings().lowercase:
column_name = column_name.lower()

sql_type_codes.append(col["DataType"])
# Add to description tuple (7 elements as per PEP-249)
description.append(
(
Expand All @@ -1102,6 +1111,7 @@ def _initialize_description(self, column_metadata: Optional[Any] = None) -> None
)
)
self.description = description
self._column_sql_types = sql_type_codes

def _build_converter_map(self):
"""
Expand All @@ -1116,15 +1126,23 @@ def _build_converter_map(self):
):
return None

sql_type_codes = self._column_sql_types
converter_map = []

for desc in self.description:
for i, desc in enumerate(self.description):
if desc is None:
converter_map.append(None)
continue
sql_type = desc[1]
converter = self.connection.get_output_converter(sql_type)
# If no converter found for the SQL type, try the WVARCHAR converter as a fallback
converter = None
# 1) pyodbc-compatible: dispatch on the raw ODBC integer SQL type code
# (e.g. SQL_DECIMAL). This is the key add_output_converter documents. #684
if sql_type_codes is not None and i < len(sql_type_codes):
converter = self.connection.get_output_converter(sql_type_codes[i])
# 2) fall back to the Python type stored in description[i][1]
# (e.g. decimal.Decimal) - the pre-existing mssql-python key style.
if converter is None:
converter = self.connection.get_output_converter(desc[1])
# 3) last-resort WVARCHAR converter fallback (legacy behavior)
if converter is None:
from mssql_python.constants import ConstantsDDBC

Expand Down Expand Up @@ -1582,6 +1600,7 @@ def execute( # pylint: disable=too-many-locals,too-many-branches,too-many-state
except Exception as e: # pylint: disable=broad-exception-caught
# If describe fails, it's likely there are no results (e.g., for INSERT)
self.description = None
self._column_sql_types = None

# Reset rownumber for new result set (only for SELECT statements)
if self.description: # If we have column descriptions, it's likely a SELECT
Expand Down Expand Up @@ -1651,6 +1670,11 @@ def _prepare_metadata_result_set( # pylint: disable=too-many-statements
if not self.description and fallback_description:
self.description = fallback_description

# Build the converter map so output converters (including integer SQL-type
# keys) apply to metadata result sets consistently with normal result sets.
# See GH #684.
self._cached_converter_map = self._build_converter_map()

# Build the column-name -> index map for this metadata result set.
# Both the exact name and its lowercase alias are stored so that rows
# support case-insensitive lookup regardless of the global ``lowercase``
Expand Down Expand Up @@ -2452,6 +2476,7 @@ def executemany( # pylint: disable=too-many-locals,too-many-branches,too-many-s
self._initialize_description(column_metadata)
except Exception: # pylint: disable=broad-exception-caught
self.description = None
self._column_sql_types = None

if self.description:
self.rowcount = -1
Expand Down Expand Up @@ -2781,6 +2806,7 @@ def nextset(self) -> Optional[bool]:
self._cached_column_map = None
self._cached_column_map_lower = None
self._cached_converter_map = None
self._column_sql_types = None
self._uuid_str_indices = None

# Skip to the next result set
Expand Down
4 changes: 3 additions & 1 deletion mssql_python/mssql_python.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,9 @@ class Connection:
) -> None: ...
def getdecoding(self, sqltype: int) -> Dict[str, Union[str, int]]: ...
def set_attr(self, attribute: int, value: Union[int, str, bytes, bytearray]) -> None: ...
def add_output_converter(self, sqltype: int, func: Callable[[Any], Any]) -> None: ...
def add_output_converter(
self, sqltype: Union[int, type], func: Callable[[Any], Any]
) -> None: ...
def get_output_converter(self, sqltype: Union[int, type]) -> Optional[Callable[[Any], Any]]: ...
def remove_output_converter(self, sqltype: Union[int, type]) -> None: ...
def clear_output_converters(self) -> None: ...
Expand Down
Loading
Loading