Skip to content

Commit 85f8659

Browse files
authored
FEAT: add native_uuid support (#463)
### Work Item / Issue Reference <!-- IMPORTANT: Please follow the PR template guidelines below. For mssql-python maintainers: Insert your ADO Work Item ID below For external contributors: Insert Github Issue number below Only one reference is required - either GitHub issue OR ADO Work Item. --> <!-- mssql-python maintainers: ADO Work Item --> > [AB#42905](https://sqlclientdrivers.visualstudio.com/c6d89619-62de-46a0-8b46-70b92a84d85e/_workitems/edit/42905) <!-- External contributors: GitHub Issue --> > GitHub Issue: #447 ------------------------------------------------------------------- ### Summary This pull request introduces support for native UUID handling in the `mssql_python` package, allowing users to control whether SQL Server `UNIQUEIDENTIFIER` columns are returned as `uuid.UUID` objects or as strings. The feature is configurable at both the module and per-connection levels, enabling incremental adoption and backward compatibility with pyodbc-style string UUIDs. The implementation ensures efficient conversion with minimal runtime overhead and updates the public API, documentation, and type hints accordingly. **Native UUID Handling** * Added a new `native_uuid` setting to the global settings (`Settings` class in `helpers.py`) and exposed it as a property on the module, allowing users to control UUID handling globally. (`mssql_python/helpers.py`, `mssql_python/__init__.py`) * Extended the `Connection` and `connect` API to accept a `native_uuid` parameter, enabling per-connection overrides of the global setting. Updated docstrings and type hints to document this parameter. (`mssql_python/connection.py`, `mssql_python/db_connection.py`, `mssql_python/mssql_python.pyi`) **Cursor and Row Conversion Logic** * Updated the `Cursor` class to determine the effective `native_uuid` setting and efficiently precompute which columns require conversion to string, minimizing per-row overhead. (`mssql_python/cursor.py`) * Modified the `Row` class to accept a list of UUID column indices and convert those columns to uppercase strings only when `native_uuid=False`, preserving pyodbc compatibility and allowing seamless migration. (`mssql_python/row.py`, `mssql_python/mssql_python.pyi`) **Testing and Minor Updates** * Updated test imports to include the new `native_uuid` symbol. (`tests/test_001_globals.py`) * Minor formatting and docstring updates for clarity and consistency. (`tests/test_001_globals.py`) These changes provide a robust and flexible way for users to opt in to native UUID support, with clear migration paths and minimal performance impact.
1 parent 7388593 commit 85f8659

9 files changed

Lines changed: 1080 additions & 46 deletions

File tree

mssql_python/__init__.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,24 @@ def lowercase(self, value: bool) -> None:
339339
with _settings_lock:
340340
_settings.lowercase = value
341341

342+
@property
343+
def native_uuid(self) -> bool:
344+
"""Get the native_uuid setting.
345+
346+
Controls whether UNIQUEIDENTIFIER columns return uuid.UUID objects (True)
347+
or str (False). Default is True.
348+
Set to False to return str for pyodbc-compatible migration.
349+
"""
350+
return _settings.native_uuid
351+
352+
@native_uuid.setter
353+
def native_uuid(self, value: bool) -> None:
354+
"""Set the native_uuid setting."""
355+
if not isinstance(value, bool):
356+
raise ValueError("native_uuid must be a boolean value")
357+
with _settings_lock:
358+
_settings.native_uuid = value
359+
342360

343361
# Replace the current module with our custom module class
344362
old_module: types.ModuleType = sys.modules[__name__]
@@ -357,3 +375,4 @@ def lowercase(self, value: bool) -> None:
357375

358376
# Initialize property values
359377
lowercase: bool = _settings.lowercase
378+
native_uuid: bool = _settings.native_uuid

mssql_python/connection.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ def __init__(
203203
autocommit: bool = False,
204204
attrs_before: Optional[Dict[int, Union[int, str, bytes]]] = None,
205205
timeout: int = 0,
206+
native_uuid: Optional[bool] = None,
206207
**kwargs: Any,
207208
) -> None:
208209
"""
@@ -219,6 +220,9 @@ def __init__(
219220
connecting, such as SQL_ATTR_LOGIN_TIMEOUT,
220221
SQL_ATTR_ODBC_CURSORS, and SQL_ATTR_PACKET_SIZE.
221222
timeout (int): Login timeout in seconds. 0 means no timeout.
223+
native_uuid (bool, optional): Controls whether UNIQUEIDENTIFIER columns return
224+
uuid.UUID objects (True) or str (False) for cursors created from this connection.
225+
None (default) defers to the module-level ``mssql_python.native_uuid`` setting (True).
222226
**kwargs: Additional key/value pairs for the connection string.
223227
224228
Returns:
@@ -236,7 +240,16 @@ def __init__(
236240
>>> import mssql_python as ms
237241
>>> conn = ms.connect("Server=myserver;Database=mydb",
238242
... attrs_before={ms.SQL_ATTR_LOGIN_TIMEOUT: 30})
243+
244+
>>> # Return native uuid.UUID objects instead of strings
245+
>>> conn = ms.connect("Server=myserver;Database=mydb", native_uuid=True)
239246
"""
247+
# Store per-connection native_uuid override.
248+
# None means "use module-level mssql_python.native_uuid".
249+
if native_uuid is not None and not isinstance(native_uuid, bool):
250+
raise ValueError("native_uuid must be a boolean value or None")
251+
self._native_uuid = native_uuid
252+
240253
self.connection_str = self._construct_connection_string(connection_str, **kwargs)
241254
self._attrs_before = attrs_before or {}
242255

mssql_python/cursor.py

Lines changed: 73 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,10 @@ def __init__(self, connection: "Connection", timeout: int = 0) -> None:
135135

136136
self._cached_column_map = None
137137
self._cached_converter_map = None
138+
self._uuid_str_indices = None # Pre-computed UUID column indices for str conversion
139+
# Cache the effective native_uuid setting for this cursor's connection.
140+
# Resolution order: connection._native_uuid (if not None) → module-level setting.
141+
self._conn_native_uuid = getattr(self.connection, "_native_uuid", None)
138142
self._next_row_index = 0 # internal: index of the next row the driver will return (0-based)
139143
self._has_result_set = False # Track if we have an active result set
140144
self._skip_increment_for_next_fetch = (
@@ -1009,6 +1013,32 @@ def _build_converter_map(self):
10091013

10101014
return converter_map
10111015

1016+
def _compute_uuid_str_indices(self):
1017+
"""
1018+
Compute the tuple of column indices whose uuid.UUID values should be
1019+
stringified (as uppercase), based on the effective native_uuid setting.
1020+
1021+
Resolution order: connection-level (if set) → module-level (fallback).
1022+
1023+
Returns:
1024+
tuple of int or None: Column indices to stringify, or None when
1025+
native_uuid is True — meaning zero per-row overhead.
1026+
"""
1027+
if not self.description:
1028+
return None
1029+
1030+
effective_native_uuid = (
1031+
self._conn_native_uuid
1032+
if self._conn_native_uuid is not None
1033+
else get_settings().native_uuid
1034+
)
1035+
if not effective_native_uuid:
1036+
indices = tuple(
1037+
i for i, desc in enumerate(self.description) if desc and desc[1] is uuid.UUID
1038+
)
1039+
return indices if indices else None
1040+
return None
1041+
10121042
def _get_column_and_converter_maps(self):
10131043
"""
10141044
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
14291459
col_desc[0]: i for i, col_desc in enumerate(self.description)
14301460
}
14311461
self._cached_converter_map = self._build_converter_map()
1462+
self._uuid_str_indices = self._compute_uuid_str_indices()
14321463
else:
14331464
self.rowcount = ddbc_bindings.DDBCSQLRowCount(self.hstmt)
14341465
self._clear_rownumber()
14351466
self._cached_column_map = None
14361467
self._cached_converter_map = None
1437-
1438-
# After successful execution, initialize description if there are results
1439-
column_metadata = []
1440-
try:
1441-
ddbc_bindings.DDBCSQLDescribeCol(self.hstmt, column_metadata)
1442-
self._initialize_description(column_metadata)
1443-
except Exception as e:
1444-
# If describe fails, it's likely there are no results (e.g., for INSERT)
1445-
self.description = None
1468+
self._uuid_str_indices = None
14461469

14471470
self._reset_inputsizes() # Reset input sizes after execution
14481471
# Return self for method chaining
@@ -2273,14 +2296,29 @@ def executemany( # pylint: disable=too-many-locals,too-many-branches,too-many-s
22732296
check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, ret)
22742297
self.rowcount = ddbc_bindings.DDBCSQLRowCount(self.hstmt)
22752298
self.last_executed_stmt = operation
2276-
self._initialize_description()
2299+
2300+
# Fetch column metadata (e.g. for INSERT … OUTPUT)
2301+
column_metadata = []
2302+
try:
2303+
ddbc_bindings.DDBCSQLDescribeCol(self.hstmt, column_metadata)
2304+
self._initialize_description(column_metadata)
2305+
except Exception: # pylint: disable=broad-exception-caught
2306+
self.description = None
22772307

22782308
if self.description:
22792309
self.rowcount = -1
22802310
self._reset_rownumber()
2311+
self._cached_column_map = {
2312+
col_desc[0]: i for i, col_desc in enumerate(self.description)
2313+
}
2314+
self._cached_converter_map = self._build_converter_map()
2315+
self._uuid_str_indices = self._compute_uuid_str_indices()
22812316
else:
22822317
self.rowcount = ddbc_bindings.DDBCSQLRowCount(self.hstmt)
22832318
self._clear_rownumber()
2319+
self._cached_column_map = None
2320+
self._cached_converter_map = None
2321+
self._uuid_str_indices = None
22842322
finally:
22852323
# Reset input sizes after execution
22862324
self._reset_inputsizes()
@@ -2328,7 +2366,13 @@ def fetchone(self) -> Union[None, Row]:
23282366

23292367
# Get column and converter maps
23302368
column_map, converter_map = self._get_column_and_converter_maps()
2331-
return Row(row_data, column_map, cursor=self, converter_map=converter_map)
2369+
return Row(
2370+
row_data,
2371+
column_map,
2372+
cursor=self,
2373+
converter_map=converter_map,
2374+
uuid_str_indices=self._uuid_str_indices,
2375+
)
23322376
except Exception as e:
23332377
# On error, don't increment rownumber - rethrow the error
23342378
raise e
@@ -2386,8 +2430,15 @@ def fetchmany(self, size: Optional[int] = None) -> List[Row]:
23862430
column_map, converter_map = self._get_column_and_converter_maps()
23872431

23882432
# Convert raw data to Row objects
2433+
uuid_idx = self._uuid_str_indices
23892434
return [
2390-
Row(row_data, column_map, cursor=self, converter_map=converter_map)
2435+
Row(
2436+
row_data,
2437+
column_map,
2438+
cursor=self,
2439+
converter_map=converter_map,
2440+
uuid_str_indices=uuid_idx,
2441+
)
23912442
for row_data in rows_data
23922443
]
23932444
except Exception as e:
@@ -2439,8 +2490,15 @@ def fetchall(self) -> List[Row]:
24392490
column_map, converter_map = self._get_column_and_converter_maps()
24402491

24412492
# Convert raw data to Row objects
2493+
uuid_idx = self._uuid_str_indices
24422494
return [
2443-
Row(row_data, column_map, cursor=self, converter_map=converter_map)
2495+
Row(
2496+
row_data,
2497+
column_map,
2498+
cursor=self,
2499+
converter_map=converter_map,
2500+
uuid_str_indices=uuid_idx,
2501+
)
24442502
for row_data in rows_data
24452503
]
24462504
except Exception as e:
@@ -2466,6 +2524,7 @@ def nextset(self) -> Union[bool, None]:
24662524
# Clear cached column and converter maps for the new result set
24672525
self._cached_column_map = None
24682526
self._cached_converter_map = None
2527+
self._uuid_str_indices = None
24692528

24702529
# Skip to the next result set
24712530
ret = ddbc_bindings.DDBCSQLMoreResults(self.hstmt)
@@ -2491,6 +2550,7 @@ def nextset(self) -> Union[bool, None]:
24912550
col_desc[0]: i for i, col_desc in enumerate(self.description)
24922551
}
24932552
self._cached_converter_map = self._build_converter_map()
2553+
self._uuid_str_indices = self._compute_uuid_str_indices()
24942554
except Exception as e: # pylint: disable=broad-exception-caught
24952555
# If describe fails, there might be no results in this result set
24962556
self.description = None

mssql_python/db_connection.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ def connect(
1414
autocommit: bool = False,
1515
attrs_before: Optional[Dict[int, Union[int, str, bytes]]] = None,
1616
timeout: int = 0,
17+
native_uuid: Optional[bool] = None,
1718
**kwargs: Any,
1819
) -> Connection:
1920
"""
@@ -22,10 +23,18 @@ def connect(
2223
Args:
2324
connection_str (str): The connection string to connect to.
2425
autocommit (bool): If True, causes a commit to be performed after each SQL statement.
25-
TODO: Add the following parameters to the function signature:
26+
attrs_before (dict, optional): A dictionary of connection attributes to set before
27+
connecting.
2628
timeout (int): The timeout for the connection attempt, in seconds.
27-
readonly (bool): If True, the connection is set to read-only.
28-
attrs_before (dict): A dictionary of connection attributes to set before connecting.
29+
native_uuid (bool, optional): Controls whether UNIQUEIDENTIFIER columns return
30+
uuid.UUID objects (True) or str (False) for this connection.
31+
- True: UNIQUEIDENTIFIER columns return uuid.UUID objects.
32+
- False: UNIQUEIDENTIFIER columns return str (pyodbc-compatible).
33+
- None (default): Uses the module-level ``mssql_python.native_uuid`` setting (True).
34+
35+
This per-connection override is useful for migration from pyodbc:
36+
connections that need string UUIDs can pass native_uuid=False, while the default (True)
37+
returns native uuid.UUID objects.
2938
Keyword Args:
3039
**kwargs: Additional key/value pairs for the connection string.
3140
Below attributes are not implemented in the internal driver:
@@ -44,6 +53,11 @@ def connect(
4453
transactions, and closing the connection.
4554
"""
4655
conn = Connection(
47-
connection_str, autocommit=autocommit, attrs_before=attrs_before, timeout=timeout, **kwargs
56+
connection_str,
57+
autocommit=autocommit,
58+
attrs_before=attrs_before,
59+
timeout=timeout,
60+
native_uuid=native_uuid,
61+
**kwargs,
4862
)
4963
return conn

mssql_python/helpers.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,13 +360,17 @@ class Settings:
360360
Settings class for mssql_python package configuration.
361361
362362
This class holds global settings that affect the behavior of the package,
363-
including lowercase column names, decimal separator.
363+
including lowercase column names, decimal separator, and UUID handling.
364364
"""
365365

366366
def __init__(self) -> None:
367367
self.lowercase: bool = False
368368
# Use the pre-determined separator - no locale access here
369369
self.decimal_separator: str = _default_decimal_separator
370+
# Controls whether UNIQUEIDENTIFIER columns return uuid.UUID (True)
371+
# or str (False). Default True returns native uuid.UUID objects.
372+
# Set to False to return str for pyodbc-compatible migration.
373+
self.native_uuid: bool = True
370374

371375

372376
# Global settings instance

mssql_python/mssql_python.pyi

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -129,21 +129,11 @@ class Row:
129129

130130
def __init__(
131131
self,
132-
cursor: "Cursor",
133-
description: List[
134-
Tuple[
135-
str,
136-
Any,
137-
Optional[int],
138-
Optional[int],
139-
Optional[int],
140-
Optional[int],
141-
Optional[bool],
142-
]
143-
],
144132
values: List[Any],
145-
column_map: Optional[Dict[str, int]] = None,
146-
settings_snapshot: Optional[Dict[str, Any]] = None,
133+
column_map: Dict[str, int],
134+
cursor: Optional["Cursor"] = None,
135+
converter_map: Optional[List[Any]] = None,
136+
uuid_str_indices: Optional[Tuple[int, ...]] = None,
147137
) -> None: ...
148138
def __getitem__(self, index: int) -> Any: ...
149139
def __getattr__(self, name: str) -> Any: ...
@@ -247,6 +237,7 @@ class Connection:
247237
autocommit: bool = False,
248238
attrs_before: Optional[Dict[int, Union[int, str, bytes]]] = None,
249239
timeout: int = 0,
240+
native_uuid: Optional[bool] = None,
250241
**kwargs: Any,
251242
) -> None: ...
252243

@@ -289,6 +280,7 @@ def connect(
289280
autocommit: bool = False,
290281
attrs_before: Optional[Dict[int, Union[int, str, bytes]]] = None,
291282
timeout: int = 0,
283+
native_uuid: Optional[bool] = None,
292284
**kwargs: Any,
293285
) -> Connection: ...
294286

mssql_python/row.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"""
77

88
import decimal
9+
import uuid as _uuid
910
from typing import Any
1011
from mssql_python.helpers import get_settings
1112
from mssql_python.logging import logger
@@ -26,14 +27,17 @@ class Row:
2627
print(row.column_name) # Access by column name (case sensitivity varies)
2728
"""
2829

29-
def __init__(self, values, column_map, cursor=None, converter_map=None):
30+
def __init__(self, values, column_map, cursor=None, converter_map=None, uuid_str_indices=None):
3031
"""
3132
Initialize a Row object with values and pre-built column map.
3233
Args:
3334
values: List of values for this row
3435
column_map: Pre-built column name to index mapping (shared across rows)
3536
cursor: Optional cursor reference (for backward compatibility and lowercase access)
3637
converter_map: Pre-computed converter map (shared across rows for performance)
38+
uuid_str_indices: Tuple of column indices whose uuid.UUID values should be
39+
converted to str. Pre-computed once per result set when native_uuid=False.
40+
None means no conversion (native_uuid=True, the default).
3741
"""
3842
# Apply output converters if available using pre-computed converter map
3943
if converter_map:
@@ -48,9 +52,33 @@ def __init__(self, values, column_map, cursor=None, converter_map=None):
4852
else:
4953
self._values = values
5054

55+
# Convert UUID columns to str when native_uuid=False.
56+
# uuid_str_indices is pre-computed once at execute() time, so this is
57+
# O(num_uuid_columns) per row — zero cost when native_uuid=True (the default).
58+
if uuid_str_indices:
59+
self._stringify_uuids(uuid_str_indices)
60+
5161
self._column_map = column_map
5262
self._cursor = cursor
5363

64+
def _stringify_uuids(self, indices):
65+
"""
66+
Convert uuid.UUID values at the given column indices to uppercase str in-place.
67+
68+
This is only called when native_uuid=False. It operates directly on
69+
self._values to avoid creating an extra list copy.
70+
"""
71+
vals = self._values
72+
# If values are still the original list (no converters), we need a mutable copy
73+
if not isinstance(vals, list):
74+
vals = list(vals)
75+
self._values = vals
76+
77+
for i in indices:
78+
v = vals[i]
79+
if v is not None and isinstance(v, _uuid.UUID):
80+
vals[i] = str(v).upper()
81+
5482
def _apply_output_converters(self, values, cursor):
5583
"""
5684
Apply output converters to raw values.

0 commit comments

Comments
 (0)