diff --git a/mssql_python/connection.py b/mssql_python/connection.py index 4ef00180..d27d0ce7 100644 --- a/mssql_python/connection.py +++ b/mssql_python/connection.py @@ -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: """ Register an output converter function that will be called whenever a value with the given SQL type is read from the database. @@ -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 diff --git a/mssql_python/cursor.py b/mssql_python/cursor.py index 9515ace2..3f6a463a 100644 --- a/mssql_python/cursor.py +++ b/mssql_python/cursor.py @@ -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. @@ -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"] @@ -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( ( @@ -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): """ @@ -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 @@ -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 @@ -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`` @@ -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 @@ -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 diff --git a/mssql_python/mssql_python.pyi b/mssql_python/mssql_python.pyi index ad18756e..cb9d8928 100644 --- a/mssql_python/mssql_python.pyi +++ b/mssql_python/mssql_python.pyi @@ -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: ... diff --git a/tests/test_003_connection.py b/tests/test_003_connection.py index 590bc0c4..1f644f05 100644 --- a/tests/test_003_connection.py +++ b/tests/test_003_connection.py @@ -21,6 +21,7 @@ from mssql_python.exceptions import InterfaceError, ProgrammingError, DatabaseError import mssql_python +import decimal import sys import pytest import time @@ -1908,6 +1909,281 @@ def test_converter_integration(db_connection): db_connection.clear_output_converters() +def test_output_converter_integer_sql_type_key_gh684(db_connection): + """Integer ODBC SQL-type keys must dispatch (pyodbc-compatible). Regression for GH #684. + + add_output_converter documents an integer SQL type code, but dispatch used to key on + the Python type in cursor.description[i][1], so integer keys silently never fired. + """ + cursor = db_connection.cursor() + decimal_query = "SELECT CAST(19.99 AS DECIMAL(10, 2)) AS price" + + try: + # 1) Integer SQL-type key fires (the reported bug: it used to silently no-op). + db_connection.add_output_converter( + mssql_python.SQL_DECIMAL, lambda v: None if v is None else float(v) + ) + cursor.execute(decimal_query) + value = cursor.fetchone()[0] + assert isinstance(value, float), "Integer SQL_DECIMAL converter did not fire" + assert value == 19.99 + db_connection.clear_output_converters() + + # 2) Exact-type dispatch: an SQL_INTEGER converter must NOT touch a DECIMAL column. + db_connection.add_output_converter(mssql_python.SQL_INTEGER, lambda v: "SHOULD_NOT_FIRE") + cursor.execute(decimal_query) + value = cursor.fetchone()[0] + assert isinstance(value, decimal.Decimal), "SQL_INTEGER converter wrongly hit DECIMAL" + # ...but it does fire on an INTEGER column. + cursor.execute("SELECT CAST(42 AS INT) AS n") + assert cursor.fetchone()[0] == "SHOULD_NOT_FIRE", "SQL_INTEGER converter did not fire" + db_connection.clear_output_converters() + + # 3) Integer SQL-type key takes precedence over a Python-type key on the same column. + db_connection.add_output_converter(decimal.Decimal, lambda v: "python-type") + db_connection.add_output_converter(mssql_python.SQL_DECIMAL, lambda v: "int-type") + cursor.execute(decimal_query) + assert cursor.fetchone()[0] == "int-type", "Integer key should win over Python-type key" + db_connection.clear_output_converters() + + # 4) Distinct converters for DECIMAL vs NUMERIC (exact type, not collapsed to Decimal). + db_connection.add_output_converter(mssql_python.SQL_DECIMAL, lambda v: "D") + db_connection.add_output_converter(mssql_python.SQL_NUMERIC, lambda v: "N") + cursor.execute("SELECT CAST(1.5 AS DECIMAL(10, 2)) AS x") + assert cursor.fetchone()[0] == "D" + cursor.execute("SELECT CAST(1.5 AS NUMERIC(10, 2)) AS x") + assert cursor.fetchone()[0] == "N" + db_connection.clear_output_converters() + + # 5) NULL still yields None regardless of an integer-keyed converter. + db_connection.add_output_converter( + mssql_python.SQL_DECIMAL, lambda v: None if v is None else float(v) + ) + cursor.execute("SELECT CAST(NULL AS DECIMAL(10, 2)) AS price") + assert cursor.fetchone()[0] is None + db_connection.clear_output_converters() + + # 6) String path: an integer SQL_WVARCHAR key fires on an NVARCHAR column and + # receives the raw value as UTF-16LE bytes (the documented string contract). + db_connection.add_output_converter( + mssql_python.SQL_WVARCHAR, + lambda v: None if v is None else "CONV:" + v.decode("utf-16-le"), + ) + cursor.execute("SELECT CAST(N'hello' AS NVARCHAR(50)) AS s") + assert cursor.fetchone()[0] == "CONV:hello", "Integer SQL_WVARCHAR converter did not fire" + db_connection.clear_output_converters() + + # 7) Metadata result sets also honor output converters (GH #684 metadata path). + # Black-box check: a string column from a catalog result set must be passed + # through the registered SQL_WVARCHAR converter, not just cached internally. + db_connection.add_output_converter( + mssql_python.SQL_WVARCHAR, + lambda v: None if v is None else "CONV:" + v.decode("utf-16-le"), + ) + cursor.tables() + row = cursor.fetchone() + assert row is not None, "tables() should return at least one catalog row" + assert any( + isinstance(col, str) and col.startswith("CONV:") for col in row + ), "Metadata result sets must apply output converters to their string columns" + finally: + db_connection.clear_output_converters() + cursor.close() + + +def test_output_converter_python_type_covers_multiple_sql_types_gh684(db_connection): + """A single Python-type converter must cover every SQL type that materializes to it. + + DECIMAL, NUMERIC, MONEY and SMALLMONEY all surface as ``decimal.Decimal`` in + cursor.description[i][1], so one ``add_output_converter(decimal.Decimal, ...)`` must + fire for all four. Conversely, an integer SQL-type key is exact-match: SQL_DECIMAL + (code 3) also covers MONEY/SMALLMONEY (which report code 3) but never NUMERIC (code 2). + """ + cursor = db_connection.cursor() + query = ( + "SELECT CAST(1.10 AS DECIMAL(10, 2)) AS dec_c, " + "CAST(2.20 AS NUMERIC(10, 2)) AS num_c, " + "CAST(3.30 AS MONEY) AS money_c, " + "CAST(4.40 AS SMALLMONEY) AS sm_c" + ) + try: + # Phase 1: one Python-type converter fires for all four decimal-like columns. + db_connection.add_output_converter(decimal.Decimal, lambda v: "D:" + str(v)) + cursor.execute(query) + dec_c, num_c, money_c, sm_c = cursor.fetchone() + assert dec_c.startswith("D:"), "decimal.Decimal converter missed DECIMAL" + assert num_c.startswith("D:"), "decimal.Decimal converter missed NUMERIC" + assert money_c.startswith("D:"), "decimal.Decimal converter missed MONEY" + assert sm_c.startswith("D:"), "decimal.Decimal converter missed SMALLMONEY" + db_connection.clear_output_converters() + + # Phase 2: an integer SQL_DECIMAL key is exact-match on the ODBC code. MONEY and + # SMALLMONEY share DECIMAL's code (3), so they fire; NUMERIC (code 2) does not. + db_connection.add_output_converter(mssql_python.SQL_DECIMAL, lambda v: "INT3") + cursor.execute(query) + dec_c, num_c, money_c, sm_c = cursor.fetchone() + assert dec_c == "INT3", "SQL_DECIMAL integer key did not fire on DECIMAL" + assert money_c == "INT3", "SQL_DECIMAL integer key did not fire on MONEY" + assert sm_c == "INT3", "SQL_DECIMAL integer key did not fire on SMALLMONEY" + assert isinstance(num_c, decimal.Decimal), "SQL_DECIMAL key wrongly hit NUMERIC" + finally: + db_connection.clear_output_converters() + cursor.close() + + +def test_output_converter_python_type_datetime_and_bytes_gh684(db_connection): + """Python-type converters must work for datetime and bytes columns, not just numbers. + + DATETIME materializes to ``datetime.datetime`` and VARBINARY to ``bytes``; the value is + handed to the converter as-is (only ``str`` values are pre-encoded to UTF-16LE bytes). + """ + cursor = db_connection.cursor() + try: + db_connection.add_output_converter(datetime, lambda v: "DT:" + v.isoformat()) + db_connection.add_output_converter(bytes, lambda v: "B:" + v.hex()) + cursor.execute( + "SELECT CAST('2021-06-07 08:09:10' AS DATETIME) AS dt, " + "CAST(0x41004200 AS VARBINARY(8)) AS b" + ) + dt_val, bin_val = cursor.fetchone() + assert dt_val == "DT:2021-06-07T08:09:10", "datetime.datetime converter did not fire" + assert bin_val == "B:41004200", "bytes converter did not fire" + finally: + db_connection.clear_output_converters() + cursor.close() + + +def test_output_converter_null_value_skips_converter_gh684(db_connection): + """A SQL NULL is returned as None and the converter is never invoked for it. + + Both apply paths short-circuit on ``value is None``, so a registered converter is not + called with None. A spy proves the converter never runs on the NULL column. + """ + cursor = db_connection.cursor() + calls = [] + + def spy(value): + calls.append(value) + return "SHOULD_NOT_APPEAR" + + try: + db_connection.add_output_converter(decimal.Decimal, spy) + cursor.execute("SELECT CAST(NULL AS DECIMAL(10, 2)) AS n") + value = cursor.fetchone()[0] + assert value is None, "SQL NULL must be returned as None" + assert calls == [], "Converter must not be invoked for a NULL value" + finally: + db_connection.clear_output_converters() + cursor.close() + + +def test_output_converter_mixed_result_set_gh684(db_connection): + """Distinct converters for different Python types coexist within one result set. + + A single row exposing INT, DECIMAL, NVARCHAR and DATETIME columns must route each value + to its own type-keyed converter independently. + """ + cursor = db_connection.cursor() + try: + db_connection.add_output_converter(int, lambda v: v + 1000) + db_connection.add_output_converter(decimal.Decimal, lambda v: "DEC:" + str(v)) + db_connection.add_output_converter(str, lambda v: "STR:" + v.decode("utf-16-le")) + db_connection.add_output_converter(datetime, lambda v: "DT:" + v.isoformat()) + cursor.execute( + "SELECT CAST(7 AS INT) AS i, " + "CAST(1.50 AS DECIMAL(10, 2)) AS d, " + "CAST(N'hi' AS NVARCHAR(10)) AS s, " + "CAST('2021-06-07 08:09:10' AS DATETIME) AS dt" + ) + i_val, d_val, s_val, dt_val = cursor.fetchone() + assert i_val == 1007, "int converter did not fire on INT column" + assert d_val == "DEC:1.50", "decimal.Decimal converter did not fire on DECIMAL column" + assert s_val == "STR:hi", "str converter did not fire on NVARCHAR column" + assert dt_val == "DT:2021-06-07T08:09:10", "datetime converter did not fire on DATETIME" + finally: + db_connection.clear_output_converters() + cursor.close() + + +def test_output_converter_unsupported_keys_are_noops_gh684(db_connection): + """Keys that match no ODBC code and no materialized Python type are harmless no-ops. + + ``add_output_converter`` accepts an ``int`` or a ``type`` (pyodbc-compatible) and stores + whatever key is given. Dispatch, however, only matches an exact integer ODBC SQL type + code or a column's exact materialized Python type, so keys like ``set``, ``object`` or a + ``decimal.Decimal`` subclass are stored but never invoked. Spies prove they never fire + and the values pass through unchanged. + """ + cursor = db_connection.cursor() + + class MyDecimal(decimal.Decimal): + pass + + set_calls, object_calls, subclass_calls = [], [], [] + + def set_spy(value): + set_calls.append(value) + return "SET_FIRED" + + def object_spy(value): + object_calls.append(value) + return "OBJECT_FIRED" + + def subclass_spy(value): + subclass_calls.append(value) + return "SUBCLASS_FIRED" + + try: + db_connection.add_output_converter(set, set_spy) + db_connection.add_output_converter(object, object_spy) + db_connection.add_output_converter(MyDecimal, subclass_spy) + cursor.execute( + "SELECT CAST(5 AS INT) AS i, " + "CAST(6.50 AS DECIMAL(10, 2)) AS d, " + "CAST(N'x' AS NVARCHAR(10)) AS s, " + "CAST(0x4100 AS VARBINARY(4)) AS b" + ) + i_val, d_val, s_val, b_val = cursor.fetchone() + # None of the unsupported-key converters were ever invoked. + assert set_calls == [], "set-keyed converter must never be invoked" + assert object_calls == [], "object-keyed converter must never be invoked" + assert subclass_calls == [], "Decimal-subclass-keyed converter must never be invoked" + # Values pass through unchanged as their native Python types. + assert i_val == 5, "INT value must be unchanged by a no-op converter" + assert isinstance(d_val, decimal.Decimal), "DECIMAL value must remain decimal.Decimal" + assert isinstance(s_val, str), "NVARCHAR value must remain str" + assert isinstance(b_val, (bytes, bytearray)), "VARBINARY value must remain bytes" + finally: + db_connection.clear_output_converters() + cursor.close() + + +def test_output_converter_cached_map_across_multiple_rows_gh684(db_connection): + """The per-statement converter map is cached once and applied to every fetched row. + + A converter registered before execute() must convert all rows consistently, proving the + cached converter map is reused (not rebuilt or dropped) across a multi-row fetchall(). + """ + cursor = db_connection.cursor() + try: + db_connection.add_output_converter(decimal.Decimal, lambda v: "R:" + str(v)) + cursor.execute( + "SELECT CAST(1.50 AS DECIMAL(10, 2)) AS d " + "UNION ALL SELECT CAST(2.50 AS DECIMAL(10, 2)) " + "UNION ALL SELECT CAST(3.50 AS DECIMAL(10, 2))" + ) + rows = cursor.fetchall() + assert len(rows) == 3, "Expected three rows from the UNION ALL" + assert [r[0] for r in rows] == [ + "R:1.50", + "R:2.50", + "R:3.50", + ], "Cached converter map must convert every row consistently" + finally: + db_connection.clear_output_converters() + cursor.close() + + def test_output_converter_with_null_values(db_connection): """Test that output converters handle NULL values correctly""" cursor = db_connection.cursor() diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index 5c2be217..f756e89b 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -14870,6 +14870,12 @@ def problematic_converter(value): def test_row_output_converter_general_exception(cursor, db_connection): """Test Row output converter general exception handling (Lines 198-206).""" + # Snapshot converters up front so the finally can ALWAYS restore them, even if + # an assertion below fails. Otherwise the {12: failing_converter} entry would + # leak onto the shared connection and corrupt every later VARCHAR fetch. + had_converters_attr = hasattr(cursor.connection, "_output_converters") + original_converters = getattr(cursor.connection, "_output_converters", {}) + try: # Create a table with string column drop_table_if_exists(cursor, "#pytest_exception_test") @@ -14887,17 +14893,17 @@ def test_row_output_converter_general_exception(cursor, db_connection): ) db_connection.commit() - # Create a custom output converter that will raise a general exception + # A converter that always raises, to exercise the "converter raised -> + # keep the original value" path. Registered under integer SQL type 12 + # (SQL_VARCHAR); after the GH #684 fix this integer key actually + # dispatches and string values arrive as UTF-16LE bytes, so we raise + # unconditionally rather than guarding on the decoded text. def failing_converter(value): - if value == "test_value": - raise RuntimeError("Custom converter error for testing") - return value + raise RuntimeError("Custom converter error for testing") # Add the converter to the connection (if supported) - original_converters = {} - if hasattr(cursor.connection, "_output_converters"): - original_converters = getattr(cursor.connection, "_output_converters", {}) - cursor.connection._output_converters = {12: failing_converter} # VARCHAR SQL type + if had_converters_attr: + cursor.connection._output_converters = {12: failing_converter} # SQL_VARCHAR # Fetch the data - this should trigger lines 198-206 in row.py cursor.execute("SELECT id, text_col FROM #pytest_exception_test") @@ -14912,13 +14918,13 @@ def failing_converter(value): # The exception should be handled and original value kept assert row[1] == "test_value", "Value should be kept as original due to exception handling" - # Restore original converters - if hasattr(cursor.connection, "_output_converters"): - cursor.connection._output_converters = original_converters - except Exception as e: pytest.fail(f"Output converter general exception test failed: {e}") finally: + # Always restore converters (even on assertion failure) so a leaked + # converter can never poison subsequent tests on the shared connection. + if had_converters_attr: + cursor.connection._output_converters = original_converters drop_table_if_exists(cursor, "#pytest_exception_test") db_connection.commit() @@ -16401,6 +16407,50 @@ def describe_raises(*args, **kwargs): mssql_python.native_uuid = original +def test_execute_describe_col_exception_resets_description_and_sql_types(conn_str): + """execute() must reset description AND _column_sql_types when DDBCSQLDescribeCol raises. + + Guards the except branch in execute() (GH #684) that sets both + self.description = None and self._column_sql_types = None, so a stale + per-column SQL-type list can't survive into the next converter-map build. + """ + conn = mssql_python.connect(conn_str) + cursor = conn.cursor() + try: + # Run a normal SELECT first so description and the parallel SQL-type + # codes are populated (the reset below then has something to clear). + cursor.execute("SELECT CAST(1 AS INT) AS n") + cursor.fetchall() + assert cursor.description is not None + assert cursor._column_sql_types is not None + + call_count = 0 + + def describe_raises(*args, **kwargs): + nonlocal call_count + call_count += 1 + raise RuntimeError("Simulated DDBCSQLDescribeCol failure") + + # Force DDBCSQLDescribeCol to raise so execute()'s except branch runs. + with patch.object( + mssql_python.cursor.ddbc_bindings, + "DDBCSQLDescribeCol", + side_effect=describe_raises, + ): + cursor.execute("SELECT CAST(1 AS INT) AS n") + + assert call_count >= 1, "DDBCSQLDescribeCol mock should have been called" + assert ( + cursor.description is None + ), "description should be None after DDBCSQLDescribeCol raises" + assert ( + cursor._column_sql_types is None + ), "_column_sql_types should be reset to None after DDBCSQLDescribeCol raises" + finally: + cursor.close() + conn.close() + + # ────────────────────────────────────────────────────────────────────────────── # native_uuid concurrency & thread-safety tests # ──────────────────────────────────────────────────────────────────────────────