Skip to content

Commit 45866f9

Browse files
committed
feat: add native_uuid support with pyodbc-compatible defaults
- Add module-level native_uuid property (default=False, matching pyodbc) - Add per-connection native_uuid override via connect(native_uuid=True/False) - Connection-level setting takes precedence over module-level - Snapshot native_uuid at execute() time for consistency within result sets - Return uppercase UUID strings when native_uuid=False (pyodbc compat) - Extract _compute_uuid_str_indices() helper to eliminate code duplication - Move uuid import to module-level in row.py (avoid per-row lazy import) - Fix Row.__init__ signature in .pyi stub to match implementation - Remove duplicate DDBCSQLDescribeCol call in execute() - Add comprehensive tests for both module-level and per-connection control - Thread-safe settings via _settings_lock
1 parent 95eef16 commit 45866f9

9 files changed

Lines changed: 1443 additions & 344 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 False (matching pyodbc behavior).
348+
Set to True to return native uuid.UUID objects.
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.
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: 59 additions & 12 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
@@ -2278,9 +2301,11 @@ def executemany( # pylint: disable=too-many-locals,too-many-branches,too-many-s
22782301
if self.description:
22792302
self.rowcount = -1
22802303
self._reset_rownumber()
2304+
self._uuid_str_indices = self._compute_uuid_str_indices()
22812305
else:
22822306
self.rowcount = ddbc_bindings.DDBCSQLRowCount(self.hstmt)
22832307
self._clear_rownumber()
2308+
self._uuid_str_indices = None
22842309
finally:
22852310
# Reset input sizes after execution
22862311
self._reset_inputsizes()
@@ -2328,7 +2353,13 @@ def fetchone(self) -> Union[None, Row]:
23282353

23292354
# Get column and converter maps
23302355
column_map, converter_map = self._get_column_and_converter_maps()
2331-
return Row(row_data, column_map, cursor=self, converter_map=converter_map)
2356+
return Row(
2357+
row_data,
2358+
column_map,
2359+
cursor=self,
2360+
converter_map=converter_map,
2361+
uuid_str_indices=self._uuid_str_indices,
2362+
)
23322363
except Exception as e:
23332364
# On error, don't increment rownumber - rethrow the error
23342365
raise e
@@ -2386,8 +2417,15 @@ def fetchmany(self, size: Optional[int] = None) -> List[Row]:
23862417
column_map, converter_map = self._get_column_and_converter_maps()
23872418

23882419
# Convert raw data to Row objects
2420+
uuid_idx = self._uuid_str_indices
23892421
return [
2390-
Row(row_data, column_map, cursor=self, converter_map=converter_map)
2422+
Row(
2423+
row_data,
2424+
column_map,
2425+
cursor=self,
2426+
converter_map=converter_map,
2427+
uuid_str_indices=uuid_idx,
2428+
)
23912429
for row_data in rows_data
23922430
]
23932431
except Exception as e:
@@ -2439,8 +2477,15 @@ def fetchall(self) -> List[Row]:
24392477
column_map, converter_map = self._get_column_and_converter_maps()
24402478

24412479
# Convert raw data to Row objects
2480+
uuid_idx = self._uuid_str_indices
24422481
return [
2443-
Row(row_data, column_map, cursor=self, converter_map=converter_map)
2482+
Row(
2483+
row_data,
2484+
column_map,
2485+
cursor=self,
2486+
converter_map=converter_map,
2487+
uuid_str_indices=uuid_idx,
2488+
)
24442489
for row_data in rows_data
24452490
]
24462491
except Exception as e:
@@ -2466,6 +2511,7 @@ def nextset(self) -> Union[bool, None]:
24662511
# Clear cached column and converter maps for the new result set
24672512
self._cached_column_map = None
24682513
self._cached_converter_map = None
2514+
self._uuid_str_indices = None
24692515

24702516
# Skip to the next result set
24712517
ret = ddbc_bindings.DDBCSQLMoreResults(self.hstmt)
@@ -2491,6 +2537,7 @@ def nextset(self) -> Union[bool, None]:
24912537
col_desc[0]: i for i, col_desc in enumerate(self.description)
24922538
}
24932539
self._cached_converter_map = self._build_converter_map()
2540+
self._uuid_str_indices = self._compute_uuid_str_indices()
24942541
except Exception as e: # pylint: disable=broad-exception-caught
24952542
# If describe fails, there might be no results in this result set
24962543
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 (False).
34+
35+
This per-connection override is useful for incremental adoption of native UUIDs:
36+
connections that are ready can pass native_uuid=True, while the default (False)
37+
preserves pyodbc-compatible string behavior.
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 False matches pyodbc behavior for seamless migration.
372+
# Set to True to return native uuid.UUID objects.
373+
self.native_uuid: bool = False
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: 33 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,19 @@ 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 uppercase str. Pre-computed once per result set when
40+
native_uuid=False (the default). The uppercase format matches pyodbc
41+
and SQL Server's native text representation.
42+
None means no conversion (native_uuid=True).
3743
"""
3844
# Apply output converters if available using pre-computed converter map
3945
if converter_map:
@@ -48,9 +54,35 @@ def __init__(self, values, column_map, cursor=None, converter_map=None):
4854
else:
4955
self._values = values
5056

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

66+
def _stringify_uuids(self, indices):
67+
"""
68+
Convert uuid.UUID values at the given column indices to uppercase str in-place.
69+
70+
This is only called when native_uuid=False. The uppercase format matches
71+
the behavior of pyodbc and SQL Server's native UNIQUEIDENTIFIER text
72+
representation, ensuring seamless migration. It operates directly on
73+
self._values to avoid creating an extra list copy.
74+
"""
75+
vals = self._values
76+
# If values are still the original list (no converters), we need a mutable copy
77+
if not isinstance(vals, list):
78+
vals = list(vals)
79+
self._values = vals
80+
81+
for i in indices:
82+
v = vals[i]
83+
if v is not None and isinstance(v, _uuid.UUID):
84+
vals[i] = str(v).upper()
85+
5486
def _apply_output_converters(self, values, cursor):
5587
"""
5688
Apply output converters to raw values.

0 commit comments

Comments
 (0)