diff --git a/mssql_python/__init__.py b/mssql_python/__init__.py index 88801aac..3d4675f1 100644 --- a/mssql_python/__init__.py +++ b/mssql_python/__init__.py @@ -339,6 +339,24 @@ def lowercase(self, value: bool) -> None: with _settings_lock: _settings.lowercase = value + @property + def native_uuid(self) -> bool: + """Get the native_uuid setting. + + Controls whether UNIQUEIDENTIFIER columns return uuid.UUID objects (True) + or str (False). Default is False (matching pyodbc behavior). + Set to True to return native uuid.UUID objects. + """ + return _settings.native_uuid + + @native_uuid.setter + def native_uuid(self, value: bool) -> None: + """Set the native_uuid setting.""" + if not isinstance(value, bool): + raise ValueError("native_uuid must be a boolean value") + with _settings_lock: + _settings.native_uuid = value + # Replace the current module with our custom module class old_module: types.ModuleType = sys.modules[__name__] @@ -357,3 +375,4 @@ def lowercase(self, value: bool) -> None: # Initialize property values lowercase: bool = _settings.lowercase +native_uuid: bool = _settings.native_uuid diff --git a/mssql_python/connection.py b/mssql_python/connection.py index c6c4944d..b9fdc218 100644 --- a/mssql_python/connection.py +++ b/mssql_python/connection.py @@ -203,6 +203,7 @@ def __init__( autocommit: bool = False, attrs_before: Optional[Dict[int, Union[int, str, bytes]]] = None, timeout: int = 0, + native_uuid: Optional[bool] = None, **kwargs: Any, ) -> None: """ @@ -219,6 +220,9 @@ def __init__( connecting, such as SQL_ATTR_LOGIN_TIMEOUT, SQL_ATTR_ODBC_CURSORS, and SQL_ATTR_PACKET_SIZE. timeout (int): Login timeout in seconds. 0 means no timeout. + native_uuid (bool, optional): Controls whether UNIQUEIDENTIFIER columns return + uuid.UUID objects (True) or str (False) for cursors created from this connection. + None (default) defers to the module-level ``mssql_python.native_uuid`` setting. **kwargs: Additional key/value pairs for the connection string. Returns: @@ -236,7 +240,16 @@ def __init__( >>> import mssql_python as ms >>> conn = ms.connect("Server=myserver;Database=mydb", ... attrs_before={ms.SQL_ATTR_LOGIN_TIMEOUT: 30}) + + >>> # Return native uuid.UUID objects instead of strings + >>> conn = ms.connect("Server=myserver;Database=mydb", native_uuid=True) """ + # Store per-connection native_uuid override. + # None means "use module-level mssql_python.native_uuid". + if native_uuid is not None and not isinstance(native_uuid, bool): + raise ValueError("native_uuid must be a boolean value or None") + self._native_uuid = native_uuid + self.connection_str = self._construct_connection_string(connection_str, **kwargs) self._attrs_before = attrs_before or {} diff --git a/mssql_python/cursor.py b/mssql_python/cursor.py index 316d8ed9..52fcbfd2 100644 --- a/mssql_python/cursor.py +++ b/mssql_python/cursor.py @@ -135,6 +135,10 @@ def __init__(self, connection: "Connection", timeout: int = 0) -> None: self._cached_column_map = None self._cached_converter_map = 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. + self._conn_native_uuid = getattr(self.connection, "_native_uuid", None) self._next_row_index = 0 # internal: index of the next row the driver will return (0-based) self._has_result_set = False # Track if we have an active result set self._skip_increment_for_next_fetch = ( @@ -1009,6 +1013,32 @@ def _build_converter_map(self): return converter_map + def _compute_uuid_str_indices(self): + """ + Compute the tuple of column indices whose uuid.UUID values should be + stringified (as uppercase), based on the effective native_uuid setting. + + Resolution order: connection-level (if set) → module-level (fallback). + + Returns: + tuple of int or None: Column indices to stringify, or None when + native_uuid is True — meaning zero per-row overhead. + """ + if not self.description: + return None + + effective_native_uuid = ( + self._conn_native_uuid + if self._conn_native_uuid is not None + else get_settings().native_uuid + ) + if not effective_native_uuid: + indices = tuple( + i for i, desc in enumerate(self.description) if desc and desc[1] is uuid.UUID + ) + return indices if indices else None + return None + def _get_column_and_converter_maps(self): """ Get column map and converter map for Row construction (thread-safe). @@ -1429,20 +1459,13 @@ def execute( # pylint: disable=too-many-locals,too-many-branches,too-many-state col_desc[0]: i for i, col_desc in enumerate(self.description) } self._cached_converter_map = self._build_converter_map() + self._uuid_str_indices = self._compute_uuid_str_indices() else: self.rowcount = ddbc_bindings.DDBCSQLRowCount(self.hstmt) self._clear_rownumber() self._cached_column_map = None self._cached_converter_map = None - - # After successful execution, initialize description if there are results - column_metadata = [] - try: - ddbc_bindings.DDBCSQLDescribeCol(self.hstmt, column_metadata) - self._initialize_description(column_metadata) - except Exception as e: - # If describe fails, it's likely there are no results (e.g., for INSERT) - self.description = None + self._uuid_str_indices = None self._reset_inputsizes() # Reset input sizes after execution # Return self for method chaining @@ -2273,14 +2296,29 @@ def executemany( # pylint: disable=too-many-locals,too-many-branches,too-many-s check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, ret) self.rowcount = ddbc_bindings.DDBCSQLRowCount(self.hstmt) self.last_executed_stmt = operation - self._initialize_description() + + # Fetch column metadata (e.g. for INSERT … OUTPUT) + column_metadata = [] + try: + ddbc_bindings.DDBCSQLDescribeCol(self.hstmt, column_metadata) + self._initialize_description(column_metadata) + except Exception: # pylint: disable=broad-exception-caught + self.description = None if self.description: self.rowcount = -1 self._reset_rownumber() + self._cached_column_map = { + col_desc[0]: i for i, col_desc in enumerate(self.description) + } + self._cached_converter_map = self._build_converter_map() + self._uuid_str_indices = self._compute_uuid_str_indices() else: self.rowcount = ddbc_bindings.DDBCSQLRowCount(self.hstmt) self._clear_rownumber() + self._cached_column_map = None + self._cached_converter_map = None + self._uuid_str_indices = None finally: # Reset input sizes after execution self._reset_inputsizes() @@ -2328,7 +2366,13 @@ def fetchone(self) -> Union[None, Row]: # Get column and converter maps column_map, converter_map = self._get_column_and_converter_maps() - return Row(row_data, column_map, cursor=self, converter_map=converter_map) + return Row( + row_data, + column_map, + cursor=self, + converter_map=converter_map, + uuid_str_indices=self._uuid_str_indices, + ) except Exception as e: # On error, don't increment rownumber - rethrow the error raise e @@ -2386,8 +2430,15 @@ def fetchmany(self, size: Optional[int] = None) -> List[Row]: column_map, converter_map = self._get_column_and_converter_maps() # Convert raw data to Row objects + uuid_idx = self._uuid_str_indices return [ - Row(row_data, column_map, cursor=self, converter_map=converter_map) + Row( + row_data, + column_map, + cursor=self, + converter_map=converter_map, + uuid_str_indices=uuid_idx, + ) for row_data in rows_data ] except Exception as e: @@ -2439,8 +2490,15 @@ def fetchall(self) -> List[Row]: column_map, converter_map = self._get_column_and_converter_maps() # Convert raw data to Row objects + uuid_idx = self._uuid_str_indices return [ - Row(row_data, column_map, cursor=self, converter_map=converter_map) + Row( + row_data, + column_map, + cursor=self, + converter_map=converter_map, + uuid_str_indices=uuid_idx, + ) for row_data in rows_data ] except Exception as e: @@ -2466,6 +2524,7 @@ def nextset(self) -> Union[bool, None]: # Clear cached column and converter maps for the new result set self._cached_column_map = None self._cached_converter_map = None + self._uuid_str_indices = None # Skip to the next result set ret = ddbc_bindings.DDBCSQLMoreResults(self.hstmt) @@ -2491,6 +2550,7 @@ def nextset(self) -> Union[bool, None]: col_desc[0]: i for i, col_desc in enumerate(self.description) } self._cached_converter_map = self._build_converter_map() + self._uuid_str_indices = self._compute_uuid_str_indices() except Exception as e: # pylint: disable=broad-exception-caught # If describe fails, there might be no results in this result set self.description = None diff --git a/mssql_python/db_connection.py b/mssql_python/db_connection.py index a6b8c614..765be941 100644 --- a/mssql_python/db_connection.py +++ b/mssql_python/db_connection.py @@ -14,6 +14,7 @@ def connect( autocommit: bool = False, attrs_before: Optional[Dict[int, Union[int, str, bytes]]] = None, timeout: int = 0, + native_uuid: Optional[bool] = None, **kwargs: Any, ) -> Connection: """ @@ -22,10 +23,18 @@ def connect( Args: connection_str (str): The connection string to connect to. autocommit (bool): If True, causes a commit to be performed after each SQL statement. - TODO: Add the following parameters to the function signature: + attrs_before (dict, optional): A dictionary of connection attributes to set before + connecting. timeout (int): The timeout for the connection attempt, in seconds. - readonly (bool): If True, the connection is set to read-only. - attrs_before (dict): A dictionary of connection attributes to set before connecting. + native_uuid (bool, optional): Controls whether UNIQUEIDENTIFIER columns return + uuid.UUID objects (True) or str (False) for this connection. + - True: UNIQUEIDENTIFIER columns return uuid.UUID objects. + - False: UNIQUEIDENTIFIER columns return str (pyodbc-compatible). + - None (default): Uses the module-level ``mssql_python.native_uuid`` setting (False). + + This per-connection override is useful for incremental adoption of native UUIDs: + connections that are ready can pass native_uuid=True, while the default (False) + preserves pyodbc-compatible string behavior. Keyword Args: **kwargs: Additional key/value pairs for the connection string. Below attributes are not implemented in the internal driver: @@ -44,6 +53,11 @@ def connect( transactions, and closing the connection. """ conn = Connection( - connection_str, autocommit=autocommit, attrs_before=attrs_before, timeout=timeout, **kwargs + connection_str, + autocommit=autocommit, + attrs_before=attrs_before, + timeout=timeout, + native_uuid=native_uuid, + **kwargs, ) return conn diff --git a/mssql_python/helpers.py b/mssql_python/helpers.py index 8c7b9060..18619539 100644 --- a/mssql_python/helpers.py +++ b/mssql_python/helpers.py @@ -360,13 +360,17 @@ class Settings: Settings class for mssql_python package configuration. This class holds global settings that affect the behavior of the package, - including lowercase column names, decimal separator. + including lowercase column names, decimal separator, and UUID handling. """ def __init__(self) -> None: self.lowercase: bool = False # Use the pre-determined separator - no locale access here self.decimal_separator: str = _default_decimal_separator + # Controls whether UNIQUEIDENTIFIER columns return uuid.UUID (True) + # or str (False). Default False matches pyodbc behavior for seamless migration. + # Set to True to return native uuid.UUID objects. + self.native_uuid: bool = False # Global settings instance diff --git a/mssql_python/mssql_python.pyi b/mssql_python/mssql_python.pyi index dd3fd96a..ba5dcad9 100644 --- a/mssql_python/mssql_python.pyi +++ b/mssql_python/mssql_python.pyi @@ -129,21 +129,11 @@ class Row: def __init__( self, - cursor: "Cursor", - description: List[ - Tuple[ - str, - Any, - Optional[int], - Optional[int], - Optional[int], - Optional[int], - Optional[bool], - ] - ], values: List[Any], - column_map: Optional[Dict[str, int]] = None, - settings_snapshot: Optional[Dict[str, Any]] = None, + column_map: Dict[str, int], + cursor: Optional["Cursor"] = None, + converter_map: Optional[List[Any]] = None, + uuid_str_indices: Optional[Tuple[int, ...]] = None, ) -> None: ... def __getitem__(self, index: int) -> Any: ... def __getattr__(self, name: str) -> Any: ... @@ -247,6 +237,7 @@ class Connection: autocommit: bool = False, attrs_before: Optional[Dict[int, Union[int, str, bytes]]] = None, timeout: int = 0, + native_uuid: Optional[bool] = None, **kwargs: Any, ) -> None: ... @@ -289,6 +280,7 @@ def connect( autocommit: bool = False, attrs_before: Optional[Dict[int, Union[int, str, bytes]]] = None, timeout: int = 0, + native_uuid: Optional[bool] = None, **kwargs: Any, ) -> Connection: ... diff --git a/mssql_python/row.py b/mssql_python/row.py index 57072e6d..a703db33 100644 --- a/mssql_python/row.py +++ b/mssql_python/row.py @@ -6,6 +6,7 @@ """ import decimal +import uuid as _uuid from typing import Any from mssql_python.helpers import get_settings from mssql_python.logging import logger @@ -26,7 +27,7 @@ class Row: print(row.column_name) # Access by column name (case sensitivity varies) """ - def __init__(self, values, column_map, cursor=None, converter_map=None): + def __init__(self, values, column_map, cursor=None, converter_map=None, uuid_str_indices=None): """ Initialize a Row object with values and pre-built column map. Args: @@ -34,6 +35,11 @@ def __init__(self, values, column_map, cursor=None, converter_map=None): column_map: Pre-built column name to index mapping (shared across rows) cursor: Optional cursor reference (for backward compatibility and lowercase access) converter_map: Pre-computed converter map (shared across rows for performance) + uuid_str_indices: Tuple of column indices whose uuid.UUID values should be + converted to uppercase str. Pre-computed once per result set when + native_uuid=False (the default). The uppercase format matches pyodbc + and SQL Server's native text representation. + None means no conversion (native_uuid=True). """ # Apply output converters if available using pre-computed converter map if converter_map: @@ -48,9 +54,35 @@ def __init__(self, values, column_map, cursor=None, converter_map=None): else: self._values = values + # Convert UUID columns to str when native_uuid=False (the default). + # uuid_str_indices is pre-computed once at execute() time, so this is + # O(num_uuid_columns) per row — zero cost when native_uuid=True. + if uuid_str_indices: + self._stringify_uuids(uuid_str_indices) + self._column_map = column_map self._cursor = cursor + def _stringify_uuids(self, indices): + """ + Convert uuid.UUID values at the given column indices to uppercase str in-place. + + This is only called when native_uuid=False. The uppercase format matches + the behavior of pyodbc and SQL Server's native UNIQUEIDENTIFIER text + representation, ensuring seamless migration. It operates directly on + self._values to avoid creating an extra list copy. + """ + vals = self._values + # If values are still the original list (no converters), we need a mutable copy + if not isinstance(vals, list): + vals = list(vals) + self._values = vals + + for i in indices: + v = vals[i] + if v is not None and isinstance(v, _uuid.UUID): + vals[i] = str(v).upper() + def _apply_output_converters(self, values, cursor): """ Apply output converters to raw values. diff --git a/tests/test_001_globals.py b/tests/test_001_globals.py index 7c004a13..6b60c7d3 100644 --- a/tests/test_001_globals.py +++ b/tests/test_001_globals.py @@ -21,6 +21,7 @@ lowercase, getDecimalSeparator, setDecimalSeparator, + native_uuid, ) @@ -740,3 +741,177 @@ def separator_reader_worker(): # Always make sure to clean up stop_event.set() setDecimalSeparator(original_separator) + + +def test_native_uuid_default(): + """Test that native_uuid defaults to False (matching pyodbc).""" + assert ( + mssql_python.native_uuid is False + ), "native_uuid should default to False (matching pyodbc)" + + +def test_native_uuid_type_validation(): + """Test that native_uuid only accepts boolean values.""" + original = mssql_python.native_uuid + + try: + # Test valid boolean values + mssql_python.native_uuid = True + assert mssql_python.native_uuid is True + + mssql_python.native_uuid = False + assert mssql_python.native_uuid is False + + # Test invalid types — all should raise ValueError + invalid_values = [1, 0, "True", "False", None, [], {}, "yes", "no", "t", "f"] + + for value in invalid_values: + with pytest.raises(ValueError, match="native_uuid must be a boolean value"): + mssql_python.native_uuid = value + + finally: + # Always restore original value + mssql_python.native_uuid = original + + +def test_native_uuid_settings_consistency(): + """Test that native_uuid is consistent between module property and Settings object.""" + from mssql_python import get_settings + + original = mssql_python.native_uuid + + try: + mssql_python.native_uuid = False + settings = get_settings() + assert settings.native_uuid is False, "Settings should reflect module-level change" + + mssql_python.native_uuid = True + settings = get_settings() + assert settings.native_uuid is True, "Settings should reflect module-level change" + + finally: + mssql_python.native_uuid = original + + +def test_native_uuid_thread_safety(): + """Test that native_uuid is thread-safe under concurrent access.""" + import queue + + original = mssql_python.native_uuid + results_queue = queue.Queue() + stop_event = threading.Event() + errors = [] + + def writer_thread(): + """Toggle native_uuid between True and False.""" + try: + while not stop_event.is_set(): + mssql_python.native_uuid = True + mssql_python.native_uuid = False + results_queue.put(("write", True)) + except Exception as e: + errors.append(str(e)) + + def reader_thread(): + """Read native_uuid and verify it's a boolean.""" + try: + while not stop_event.is_set(): + val = mssql_python.native_uuid + assert isinstance(val, bool), f"Expected bool, got {type(val)}" + results_queue.put(("read", val)) + except Exception as e: + errors.append(str(e)) + + threads = [] + for _ in range(3): + threads.append(threading.Thread(target=writer_thread)) + threads.append(threading.Thread(target=reader_thread)) + + for t in threads: + t.start() + + try: + time.sleep(1) # Let threads run for 1 second + stop_event.set() + + for t in threads: + t.join(timeout=1) + + assert not errors, f"Thread errors detected: {errors}" + + finally: + stop_event.set() + mssql_python.native_uuid = original + + +def test_connect_native_uuid_parameter_signature(): + """Test that connect() accepts the native_uuid parameter without errors.""" + import inspect + + sig = inspect.signature(mssql_python.connect) + params = sig.parameters + + assert "native_uuid" in params, "connect() should accept native_uuid parameter" + param = params["native_uuid"] + assert param.default is None, "native_uuid default should be None" + + +def test_connection_native_uuid_attribute(): + """Test that Connection class stores the _native_uuid attribute.""" + from mssql_python.connection import Connection + + # Connection.__init__ should accept native_uuid; we can't fully construct + # a Connection without a valid connection string, but we can verify the + # parameter is accepted by inspecting the signature. + import inspect + + sig = inspect.signature(Connection.__init__) + params = sig.parameters + assert "native_uuid" in params, "Connection.__init__ should accept native_uuid parameter" + assert params["native_uuid"].default is None + + +def test_compute_uuid_str_indices_no_description(db_connection): + """Test _compute_uuid_str_indices returns None when cursor has no description.""" + cursor = db_connection.cursor() + try: + # Execute a statement that produces no result set + cursor.execute( + "CREATE TABLE #no_desc_uuid_test (id INT); " "INSERT INTO #no_desc_uuid_test VALUES (1)" + ) + # description should be None after a non-SELECT statement + assert cursor.description is None + + # Directly call the helper — should return None via the early guard + result = cursor._compute_uuid_str_indices() + assert ( + result is None + ), "_compute_uuid_str_indices should return None when description is None" + finally: + cursor.execute("DROP TABLE IF EXISTS #no_desc_uuid_test") + cursor.close() + + +def test_stringify_uuids_with_tuple_values(): + """Test Row._stringify_uuids converts tuple values to list for in-place mutation.""" + import uuid as _uuid + from mssql_python.row import Row + + test_uuid = _uuid.UUID("12345678-1234-5678-1234-567812345678") + + # Pass values as a tuple (not a list) to trigger the isinstance guard + row = Row( + (42, test_uuid, "hello"), + {"id": 0, "guid": 1, "name": 2}, + cursor=None, + converter_map=None, + uuid_str_indices=(1,), + ) + + # The UUID should have been stringified to uppercase + assert row[1] == "12345678-1234-5678-1234-567812345678".upper() + # Other values should be unaffected + assert row[0] == 42 + assert row[2] == "hello" + # Internal storage should now be a list (converted from tuple) + assert isinstance(row._values, list) diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index 57549629..e178ba33 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -14,9 +14,11 @@ import time as time_module import decimal from contextlib import closing +import threading import mssql_python import uuid import re +from unittest.mock import patch from conftest import is_azure_sql_connection # Setup test table @@ -7430,7 +7432,9 @@ def test_sql_double_type(cursor, db_connection): def test_null_guid_type(cursor, db_connection): """Test NULL UNIQUEIDENTIFIER (GUID) to cover lines 3376-3377.""" + original_native_uuid = mssql_python.native_uuid try: + mssql_python.native_uuid = True drop_table_if_exists(cursor, "#pytest_null_guid") cursor.execute(""" CREATE TABLE #pytest_null_guid ( @@ -7481,6 +7485,7 @@ def test_null_guid_type(cursor, db_connection): pytest.fail(f"NULL GUID type test failed: {e}") finally: + mssql_python.native_uuid = original_native_uuid drop_table_if_exists(cursor, "#pytest_null_guid") db_connection.commit() @@ -8215,8 +8220,10 @@ def test_uuid_insert_and_select_none(cursor, db_connection): def test_insert_multiple_uuids(cursor, db_connection): """Test inserting multiple UUIDs and verifying retrieval.""" + original_native_uuid = mssql_python.native_uuid table_name = "#pytest_uuid_multiple" try: + mssql_python.native_uuid = True cursor.execute(f"DROP TABLE IF EXISTS {table_name}") cursor.execute(f""" CREATE TABLE {table_name} ( @@ -8250,14 +8257,17 @@ def test_insert_multiple_uuids(cursor, db_connection): retrieved_uuid == expected_uuid ), f"UUID mismatch for '{retrieved_desc}': expected {expected_uuid}, got {retrieved_uuid}" finally: + mssql_python.native_uuid = original_native_uuid cursor.execute(f"DROP TABLE IF EXISTS {table_name}") db_connection.commit() def test_fetchmany_uuids(cursor, db_connection): """Test fetching multiple UUID rows with fetchmany().""" + original_native_uuid = mssql_python.native_uuid table_name = "#pytest_uuid_fetchmany" try: + mssql_python.native_uuid = True cursor.execute(f"DROP TABLE IF EXISTS {table_name}") cursor.execute(f""" CREATE TABLE {table_name} ( @@ -8291,6 +8301,7 @@ def test_fetchmany_uuids(cursor, db_connection): expected_uuid = uuids_to_insert[retrieved_desc] assert retrieved_uuid == expected_uuid finally: + mssql_python.native_uuid = original_native_uuid cursor.execute(f"DROP TABLE IF EXISTS {table_name}") db_connection.commit() @@ -8368,8 +8379,10 @@ def test_duplicate_uuid_inserts(cursor, db_connection): def test_extreme_uuids(cursor, db_connection): """Test inserting extreme but valid UUIDs.""" + original_native_uuid = mssql_python.native_uuid table_name = "#pytest_uuid_extreme" try: + mssql_python.native_uuid = True cursor.execute(f"DROP TABLE IF EXISTS {table_name}") cursor.execute(f"CREATE TABLE {table_name} (id UNIQUEIDENTIFIER)") db_connection.commit() @@ -8390,6 +8403,7 @@ def test_extreme_uuids(cursor, db_connection): for uid in extreme_uuids: assert uid in fetched_uuids, f"Extreme UUID {uid} not retrieved correctly" finally: + mssql_python.native_uuid = original_native_uuid cursor.execute(f"DROP TABLE IF EXISTS {table_name}") db_connection.commit() @@ -15019,3 +15033,700 @@ def test_close(db_connection): pytest.fail(f"Cursor close test failed: {e}") finally: cursor = db_connection.cursor() + + +# ───────────────────────────────────────────────────────────────────── +# native_uuid tests +# ───────────────────────────────────────────────────────────────────── + + +def test_native_uuid_true_returns_uuid_objects(db_connection): + """Test that with native_uuid=True, UNIQUEIDENTIFIER columns return uuid.UUID.""" + original_value = mssql_python.native_uuid + cursor = db_connection.cursor() + + try: + mssql_python.native_uuid = True + + drop_table_if_exists(cursor, "#test_native_uuid_true") + cursor.execute( + "CREATE TABLE #test_native_uuid_true (id UNIQUEIDENTIFIER, name NVARCHAR(50))" + ) + test_uuid = uuid.uuid4() + cursor.execute("INSERT INTO #test_native_uuid_true VALUES (?, ?)", [test_uuid, "test"]) + + # fetchone + cursor.execute("SELECT id, name FROM #test_native_uuid_true") + row = cursor.fetchone() + assert isinstance(row[0], uuid.UUID), f"Expected uuid.UUID, got {type(row[0])}" + assert row[0] == test_uuid + + # fetchall + cursor.execute("SELECT id, name FROM #test_native_uuid_true") + rows = cursor.fetchall() + assert isinstance(rows[0][0], uuid.UUID), f"Expected uuid.UUID, got {type(rows[0][0])}" + assert rows[0][0] == test_uuid + + # fetchmany + cursor.execute("SELECT id, name FROM #test_native_uuid_true") + rows = cursor.fetchmany(1) + assert isinstance(rows[0][0], uuid.UUID), f"Expected uuid.UUID, got {type(rows[0][0])}" + assert rows[0][0] == test_uuid + + finally: + mssql_python.native_uuid = original_value + drop_table_if_exists(cursor, "#test_native_uuid_true") + db_connection.commit() + + +def test_native_uuid_false_returns_strings(db_connection): + """Test that with native_uuid=False, UNIQUEIDENTIFIER columns return str.""" + original_value = mssql_python.native_uuid + cursor = db_connection.cursor() + + try: + mssql_python.native_uuid = False + + drop_table_if_exists(cursor, "#test_native_uuid_false") + cursor.execute( + "CREATE TABLE #test_native_uuid_false (id UNIQUEIDENTIFIER, name NVARCHAR(50))" + ) + test_uuid = uuid.uuid4() + cursor.execute("INSERT INTO #test_native_uuid_false VALUES (?, ?)", [test_uuid, "test"]) + + # fetchone + cursor.execute("SELECT id, name FROM #test_native_uuid_false") + row = cursor.fetchone() + assert isinstance(row[0], str), f"With native_uuid=False, expected str, got {type(row[0])}" + assert ( + row[0] == str(test_uuid).upper() + ), f"UUID string mismatch: {row[0]} != {str(test_uuid).upper()}" + + # fetchall + cursor.execute("SELECT id, name FROM #test_native_uuid_false") + rows = cursor.fetchall() + assert isinstance( + rows[0][0], str + ), f"With native_uuid=False, expected str, got {type(rows[0][0])}" + assert rows[0][0] == str(test_uuid).upper() + + # fetchmany + cursor.execute("SELECT id, name FROM #test_native_uuid_false") + rows = cursor.fetchmany(1) + assert isinstance( + rows[0][0], str + ), f"With native_uuid=False, expected str, got {type(rows[0][0])}" + assert rows[0][0] == str(test_uuid).upper() + + finally: + mssql_python.native_uuid = original_value + drop_table_if_exists(cursor, "#test_native_uuid_false") + db_connection.commit() + + +def test_native_uuid_null_handling(db_connection): + """Test that NULL UNIQUEIDENTIFIER values remain None regardless of native_uuid setting.""" + original_value = mssql_python.native_uuid + cursor = db_connection.cursor() + + try: + drop_table_if_exists(cursor, "#test_uuid_null") + cursor.execute("CREATE TABLE #test_uuid_null (id INT, uuid_col UNIQUEIDENTIFIER)") + cursor.execute("INSERT INTO #test_uuid_null VALUES (1, NULL)") + + # Test with native_uuid=True + mssql_python.native_uuid = True + cursor.execute("SELECT * FROM #test_uuid_null") + row = cursor.fetchone() + assert row[1] is None, "NULL UUID should remain None with native_uuid=True" + + # Test with native_uuid=False + mssql_python.native_uuid = False + cursor.execute("SELECT * FROM #test_uuid_null") + row = cursor.fetchone() + assert row[1] is None, "NULL UUID should remain None with native_uuid=False" + + finally: + mssql_python.native_uuid = original_value + drop_table_if_exists(cursor, "#test_uuid_null") + db_connection.commit() + + +def test_native_uuid_non_uuid_columns_unaffected(db_connection): + """Test that native_uuid=False does not affect non-UUID columns.""" + original_value = mssql_python.native_uuid + cursor = db_connection.cursor() + + try: + mssql_python.native_uuid = False + + drop_table_if_exists(cursor, "#test_uuid_other_cols") + cursor.execute(""" + CREATE TABLE #test_uuid_other_cols ( + id UNIQUEIDENTIFIER, + int_col INT, + str_col NVARCHAR(50), + float_col FLOAT, + bit_col BIT + ) + """) + test_uuid = uuid.uuid4() + cursor.execute( + "INSERT INTO #test_uuid_other_cols VALUES (?, ?, ?, ?, ?)", + [test_uuid, 42, "hello", 3.14, True], + ) + + cursor.execute("SELECT * FROM #test_uuid_other_cols") + row = cursor.fetchone() + + # UUID column should be str + assert isinstance(row[0], str), f"UUID col: expected str, got {type(row[0])}" + # Other columns should retain their types + assert isinstance(row[1], int), f"INT col: expected int, got {type(row[1])}" + assert isinstance(row[2], str), f"NVARCHAR col: expected str, got {type(row[2])}" + assert isinstance(row[3], float), f"FLOAT col: expected float, got {type(row[3])}" + assert isinstance(row[4], bool), f"BIT col: expected bool, got {type(row[4])}" + + finally: + mssql_python.native_uuid = original_value + drop_table_if_exists(cursor, "#test_uuid_other_cols") + db_connection.commit() + + +def test_native_uuid_setting_snapshot_at_execute(db_connection): + """Test that native_uuid is snapshotted at execute() time, not fetch() time.""" + original_value = mssql_python.native_uuid + cursor = db_connection.cursor() + + try: + drop_table_if_exists(cursor, "#test_uuid_snapshot") + cursor.execute("CREATE TABLE #test_uuid_snapshot (id UNIQUEIDENTIFIER)") + test_uuid = uuid.uuid4() + cursor.execute("INSERT INTO #test_uuid_snapshot VALUES (?)", [test_uuid]) + + # Execute with native_uuid=False + mssql_python.native_uuid = False + cursor.execute("SELECT id FROM #test_uuid_snapshot") + + # Change setting AFTER execute but BEFORE fetch + mssql_python.native_uuid = True + + # Should still return str because setting was snapshotted at execute() + row = cursor.fetchone() + assert isinstance(row[0], str), ( + "Setting should be snapshotted at execute() time. " f"Expected str, got {type(row[0])}" + ) + + finally: + mssql_python.native_uuid = original_value + drop_table_if_exists(cursor, "#test_uuid_snapshot") + db_connection.commit() + + +def test_native_uuid_input_parameter_accepts_uuid_objects(db_connection): + """Test that uuid.UUID objects are still accepted as input parameters regardless of native_uuid.""" + original_value = mssql_python.native_uuid + cursor = db_connection.cursor() + + try: + drop_table_if_exists(cursor, "#test_uuid_input") + cursor.execute("CREATE TABLE #test_uuid_input (id UNIQUEIDENTIFIER)") + test_uuid = uuid.uuid4() + + # Insert with native_uuid=False — uuid.UUID input should still work + mssql_python.native_uuid = False + cursor.execute("INSERT INTO #test_uuid_input VALUES (?)", [test_uuid]) + + cursor.execute("SELECT id FROM #test_uuid_input") + row = cursor.fetchone() + assert isinstance(row[0], str) + assert row[0] == str(test_uuid).upper() + + # Query with UUID parameter — should also work + cursor.execute("SELECT id FROM #test_uuid_input WHERE id = ?", [test_uuid]) + row = cursor.fetchone() + assert row is not None + assert row[0] == str(test_uuid).upper() + + finally: + mssql_python.native_uuid = original_value + drop_table_if_exists(cursor, "#test_uuid_input") + db_connection.commit() + + +# ────────────────────────────────────────────────────────────────────────────── +# Per-connection native_uuid tests +# ────────────────────────────────────────────────────────────────────────────── + + +def test_per_connection_native_uuid_none_uses_module_default(conn_str): + """Test that connect(native_uuid=None) defers to module-level setting.""" + original_value = mssql_python.native_uuid + conn = mssql_python.connect(conn_str, native_uuid=None) + cursor = conn.cursor() + try: + # Module-level = True, connection = None → should return uuid.UUID + mssql_python.native_uuid = True + drop_table_if_exists(cursor, "##test_conn_uuid_none") + cursor.execute("CREATE TABLE ##test_conn_uuid_none (id UNIQUEIDENTIFIER)") + test_uuid = uuid.uuid4() + cursor.execute("INSERT INTO ##test_conn_uuid_none VALUES (?)", [test_uuid]) + cursor.execute("SELECT id FROM ##test_conn_uuid_none") + row = cursor.fetchone() + assert isinstance(row[0], uuid.UUID), "None should defer to module-level True" + + # Now change module-level to False → new cursor on same connection should return str + mssql_python.native_uuid = False + cursor2 = conn.cursor() + cursor2.execute("SELECT id FROM ##test_conn_uuid_none") + row2 = cursor2.fetchone() + assert isinstance(row2[0], str), "None should defer to module-level False" + + finally: + drop_table_if_exists(cursor, "##test_conn_uuid_none") + conn.close() + mssql_python.native_uuid = original_value + + +def test_per_connection_overrides_module_level(conn_str): + """Test that per-connection native_uuid overrides the module-level setting.""" + original_value = mssql_python.native_uuid + conn = None + conn2 = None + try: + # Module-level = True, but connection says False → strings + mssql_python.native_uuid = True + conn = mssql_python.connect(conn_str, native_uuid=False) + cursor = conn.cursor() + + drop_table_if_exists(cursor, "#test_conn_override_a") + cursor.execute("CREATE TABLE #test_conn_override_a (id UNIQUEIDENTIFIER)") + test_uuid = uuid.uuid4() + cursor.execute("INSERT INTO #test_conn_override_a VALUES (?)", [test_uuid]) + + cursor.execute("SELECT id FROM #test_conn_override_a") + row = cursor.fetchone() + assert isinstance( + row[0], str + ), f"Connection native_uuid=False should override module True, got {type(row[0])}" + + # Module-level = False, but connection says True → uuid.UUID + mssql_python.native_uuid = False + conn2 = mssql_python.connect(conn_str, native_uuid=True) + cursor2 = conn2.cursor() + drop_table_if_exists(cursor2, "#test_conn_override_b") + cursor2.execute("CREATE TABLE #test_conn_override_b (id UNIQUEIDENTIFIER)") + cursor2.execute("INSERT INTO #test_conn_override_b VALUES (?)", [test_uuid]) + + cursor2.execute("SELECT id FROM #test_conn_override_b") + row2 = cursor2.fetchone() + assert isinstance( + row2[0], uuid.UUID + ), f"Connection native_uuid=True should override module False, got {type(row2[0])}" + + drop_table_if_exists(cursor, "#test_conn_override_a") + drop_table_if_exists(cursor2, "#test_conn_override_b") + finally: + mssql_python.native_uuid = original_value + if conn: + conn.close() + if conn2: + conn2.close() + + +def test_two_connections_different_native_uuid(conn_str): + """Test that two simultaneous connections can have different native_uuid settings.""" + original_value = mssql_python.native_uuid + try: + conn_str_mode = conn_str + conn_uuid = mssql_python.connect(conn_str_mode, native_uuid=True) + conn_str_mode2 = conn_str + conn_string = mssql_python.connect(conn_str_mode2, native_uuid=False) + + cursor_uuid = conn_uuid.cursor() + cursor_string = conn_string.cursor() + + drop_table_if_exists(cursor_uuid, "#test_dual_conn") + cursor_uuid.execute("CREATE TABLE #test_dual_conn (id UNIQUEIDENTIFIER)") + test_uuid = uuid.uuid4() + cursor_uuid.execute("INSERT INTO #test_dual_conn VALUES (?)", [test_uuid]) + + # Same query, different connections → different types + cursor_uuid.execute("SELECT id FROM #test_dual_conn") + row_uuid = cursor_uuid.fetchone() + + # Need a separate temp table for the second connection since temp tables + # are connection-scoped. Use a global temp table instead. + drop_table_if_exists(cursor_string, "##test_dual_conn_shared") + cursor_string.execute("CREATE TABLE ##test_dual_conn_shared (id UNIQUEIDENTIFIER)") + cursor_string.execute("INSERT INTO ##test_dual_conn_shared VALUES (?)", [test_uuid]) + cursor_string.execute("SELECT id FROM ##test_dual_conn_shared") + row_string = cursor_string.fetchone() + + assert isinstance(row_uuid[0], uuid.UUID), f"Expected uuid.UUID, got {type(row_uuid[0])}" + assert isinstance(row_string[0], str), f"Expected str, got {type(row_string[0])}" + assert ( + str(row_uuid[0]).upper() == row_string[0] + ), "Values should be equal as uppercase strings" + + drop_table_if_exists(cursor_uuid, "#test_dual_conn") + drop_table_if_exists(cursor_string, "##test_dual_conn_shared") + conn_uuid.close() + conn_string.close() + finally: + mssql_python.native_uuid = original_value + + +def test_per_connection_native_uuid_invalid_type(conn_str): + """Test that connect(native_uuid=) raises ValueError.""" + with pytest.raises(ValueError, match="native_uuid must be a boolean"): + mssql_python.connect(conn_str, native_uuid="false") + + with pytest.raises(ValueError, match="native_uuid must be a boolean"): + mssql_python.connect(conn_str, native_uuid=1) + + +def test_executemany_uuid_output_sets_uuid_str_indices(conn_str): + """Test that executemany with OUTPUT clause computes _uuid_str_indices.""" + original = mssql_python.native_uuid + try: + mssql_python.native_uuid = False + conn = mssql_python.connect(conn_str) + cursor = conn.cursor() + + cursor.execute(""" + CREATE TABLE #executemany_uuid_output ( + id INT IDENTITY(1,1), + guid UNIQUEIDENTIFIER DEFAULT NEWID() + ) + """) + + # executemany with OUTPUT — produces a result set with a UUID column + cursor.executemany( + "INSERT INTO #executemany_uuid_output (guid) OUTPUT INSERTED.guid VALUES (?)", + [ + (uuid.UUID("11111111-1111-1111-1111-111111111111"),), + (uuid.UUID("22222222-2222-2222-2222-222222222222"),), + ], + ) + + # After executemany, description should exist (OUTPUT returns rows) + assert cursor.description is not None, "OUTPUT clause should produce a description" + + # _uuid_str_indices should have been computed (native_uuid=False + UUID column) + assert ( + cursor._uuid_str_indices is not None + ), "_uuid_str_indices should be set after executemany with OUTPUT" + + # Fetch the returned rows — should be uppercase strings, not uuid.UUID + rows = cursor.fetchall() + assert len(rows) >= 1, "OUTPUT clause should return at least one row" + for row in rows: + assert isinstance(row[0], str), f"Expected str, got {type(row[0])}" + assert row[0] == row[0].upper(), "UUID string should be uppercase" + + cursor.execute("DROP TABLE IF EXISTS #executemany_uuid_output") + cursor.close() + conn.close() + finally: + mssql_python.native_uuid = original + + +def test_executemany_no_result_set_clears_uuid_str_indices(conn_str): + """Test that executemany without OUTPUT clears description and uuid state. + + Covers the ``else`` branch (cursor.description is None) inside executemany(). + """ + original = mssql_python.native_uuid + try: + mssql_python.native_uuid = False + conn = mssql_python.connect(conn_str) + cursor = conn.cursor() + + cursor.execute(""" + CREATE TABLE #executemany_no_output ( + id INT, + guid UNIQUEIDENTIFIER + ) + """) + + # Plain INSERT — no OUTPUT clause, no result set produced + cursor.executemany( + "INSERT INTO #executemany_no_output (id, guid) VALUES (?, ?)", + [ + (1, uuid.UUID("11111111-1111-1111-1111-111111111111")), + (2, uuid.UUID("22222222-2222-2222-2222-222222222222")), + ], + ) + + # description should be None + assert cursor.description is None, "Plain INSERT should have no description" + # _uuid_str_indices should be None + assert cursor._uuid_str_indices is None + # rowcount should reflect the inserted rows + assert cursor.rowcount == 2 + + cursor.execute("DROP TABLE IF EXISTS #executemany_no_output") + cursor.close() + conn.close() + finally: + mssql_python.native_uuid = original + + +def test_executemany_describe_col_exception_sets_description_none(conn_str): + """Test that executemany handles DDBCSQLDescribeCol raising an exception. + + The except handler that sets + self.description = None when the C++ binding raises during column + metadata retrieval. + """ + original = mssql_python.native_uuid + try: + mssql_python.native_uuid = False + conn = mssql_python.connect(conn_str) + cursor = conn.cursor() + + cursor.execute(""" + CREATE TABLE #executemany_except_test ( + id INT, + guid UNIQUEIDENTIFIER + ) + """) + + # Capture the real DDBCSQLDescribeCol so we can patch only during executemany + real_describe = mssql_python.cursor.ddbc_bindings.DDBCSQLDescribeCol + call_count = 0 + + def describe_raises(*args, **kwargs): + """Raise on the first call (inside executemany), delegate otherwise.""" + nonlocal call_count + call_count += 1 + raise RuntimeError("Simulated DDBCSQLDescribeCol failure") + + # executemany with OUTPUT — would normally produce a result set, but we + # force DDBCSQLDescribeCol to raise so the except branch is taken. + with patch.object( + mssql_python.cursor.ddbc_bindings, + "DDBCSQLDescribeCol", + side_effect=describe_raises, + ): + cursor.executemany( + "INSERT INTO #executemany_except_test (id, guid) OUTPUT INSERTED.guid VALUES (?, ?)", + [ + (1, uuid.UUID("11111111-1111-1111-1111-111111111111")), + ], + ) + + # The except branch should have set description to None + assert ( + cursor.description is None + ), "description should be None after DDBCSQLDescribeCol raises" + assert cursor._uuid_str_indices is None + assert call_count >= 1, "DDBCSQLDescribeCol mock should have been called" + + cursor.execute("DROP TABLE IF EXISTS #executemany_except_test") + cursor.close() + conn.close() + finally: + mssql_python.native_uuid = original + + +# ────────────────────────────────────────────────────────────────────────────── +# native_uuid concurrency & thread-safety tests +# ────────────────────────────────────────────────────────────────────────────── + + +def test_native_uuid_concurrent_connections_isolation(conn_str): + """Multiple threads with different per-connection native_uuid execute simultaneously. + + Verifies that per-connection native_uuid settings are fully isolated: + each thread's results match its own connection's setting regardless of + what other threads are doing concurrently. + """ + NUM_THREADS = 6 + ITERATIONS = 5 + errors = [] + barrier = threading.Barrier(NUM_THREADS) + + def worker(thread_id, native_uuid_setting): + """Each thread creates its own connection and verifies return types.""" + try: + conn = mssql_python.connect(conn_str, native_uuid=native_uuid_setting) + cursor = conn.cursor() + table = f"#concurrent_uuid_{thread_id}" + + try: + cursor.execute(f"CREATE TABLE {table} (id UNIQUEIDENTIFIER)") + test_uuid = uuid.uuid4() + cursor.execute(f"INSERT INTO {table} VALUES (?)", [test_uuid]) + + # Synchronize — all threads start querying at the same time + barrier.wait(timeout=10) + + for _ in range(ITERATIONS): + cursor.execute(f"SELECT id FROM {table}") + row = cursor.fetchone() + if native_uuid_setting: + if not isinstance(row[0], uuid.UUID): + errors.append( + f"Thread {thread_id}: expected uuid.UUID, " f"got {type(row[0])}" + ) + else: + if not isinstance(row[0], str): + errors.append( + f"Thread {thread_id}: expected str, " f"got {type(row[0])}" + ) + + cursor.execute(f"DROP TABLE IF EXISTS {table}") + finally: + conn.close() + except Exception as e: + errors.append(f"Thread {thread_id}: {e}") + + threads = [] + for i in range(NUM_THREADS): + # Alternate True / False across threads + setting = i % 2 == 0 + t = threading.Thread(target=worker, args=(i, setting)) + threads.append(t) + + for t in threads: + t.start() + for t in threads: + t.join(timeout=30) + + assert not errors, f"Concurrent connection isolation errors: {errors}" + + +def test_native_uuid_snapshot_under_concurrent_modification(conn_str): + """Snapshot-at-execute holds even when another thread modifies the module setting. + + Thread A executes with native_uuid=False, then thread B toggles the + module setting to True *before* thread A fetches. Thread A must still + receive strings because the setting was snapshotted at execute() time. + """ + original = mssql_python.native_uuid + errors = [] + executed_event = threading.Event() # A signals after execute() + toggled_event = threading.Event() # B signals after toggling + + try: + mssql_python.native_uuid = False + conn = mssql_python.connect(conn_str) # native_uuid=None → uses module + cursor = conn.cursor() + + cursor.execute("CREATE TABLE #snapshot_conc (id UNIQUEIDENTIFIER)") + test_uuid = uuid.uuid4() + cursor.execute("INSERT INTO #snapshot_conc VALUES (?)", [test_uuid]) + + def thread_a(): + """Execute with False, wait for toggle, then fetch.""" + try: + cursor.execute("SELECT id FROM #snapshot_conc") + executed_event.set() # signal: execute() done + toggled_event.wait(timeout=10) # wait for B to toggle + row = cursor.fetchone() + if not isinstance(row[0], str): + errors.append( + f"Thread A: snapshot broken — expected str, " f"got {type(row[0])}" + ) + except Exception as e: + errors.append(f"Thread A: {e}") + + def thread_b(): + """Wait for A to execute, then toggle module setting.""" + try: + executed_event.wait(timeout=10) + mssql_python.native_uuid = True # toggle after execute + toggled_event.set() + except Exception as e: + errors.append(f"Thread B: {e}") + + ta = threading.Thread(target=thread_a) + tb = threading.Thread(target=thread_b) + ta.start() + tb.start() + ta.join(timeout=15) + tb.join(timeout=15) + + assert not errors, f"Snapshot-under-concurrency errors: {errors}" + + cursor.execute("DROP TABLE IF EXISTS #snapshot_conc") + conn.close() + finally: + mssql_python.native_uuid = original + + +def test_native_uuid_concurrent_toggle_consistency(conn_str): + """One thread rapidly toggles module-level native_uuid while others query. + + Each querying thread must get a *consistent* result for each execute/fetch + cycle — either all uuid.UUID or all str within a single cursor, never a + mix. This validates that the snapshot-at-execute design prevents torn reads. + """ + original = mssql_python.native_uuid + NUM_READERS = 4 + ITERATIONS = 10 + errors = [] + stop_event = threading.Event() + + def toggler(): + """Rapidly flip the module-level native_uuid flag.""" + try: + while not stop_event.is_set(): + mssql_python.native_uuid = True + mssql_python.native_uuid = False + except Exception as e: + errors.append(f"Toggler: {e}") + + def reader(reader_id): + """Open a connection and repeatedly execute + fetch, checking consistency.""" + try: + conn = mssql_python.connect(conn_str) # native_uuid=None → module + cursor = conn.cursor() + table = f"#toggle_reader_{reader_id}" + + try: + cursor.execute(f"CREATE TABLE {table} (id UNIQUEIDENTIFIER)") + uuids = [uuid.uuid4() for _ in range(3)] + for u in uuids: + cursor.execute(f"INSERT INTO {table} VALUES (?)", [u]) + + for _ in range(ITERATIONS): + cursor.execute(f"SELECT id FROM {table}") + rows = cursor.fetchall() + types = {type(r[0]) for r in rows} + + # All rows in a single fetch must be the same type + if len(types) != 1: + errors.append( + f"Reader {reader_id}: mixed types in single " f"fetch: {types}" + ) + + cursor.execute(f"DROP TABLE IF EXISTS {table}") + finally: + conn.close() + except Exception as e: + errors.append(f"Reader {reader_id}: {e}") + + try: + toggle_thread = threading.Thread(target=toggler, daemon=True) + toggle_thread.start() + + reader_threads = [] + for i in range(NUM_READERS): + t = threading.Thread(target=reader, args=(i,)) + reader_threads.append(t) + + for t in reader_threads: + t.start() + for t in reader_threads: + t.join(timeout=30) + + stop_event.set() + toggle_thread.join(timeout=5) + + assert not errors, f"Concurrent toggle consistency errors: {errors}" + finally: + stop_event.set() + mssql_python.native_uuid = original