Skip to content

Commit 7598b35

Browse files
authored
FEAT: uniqueidentifier support in executemany() (#245)
### Work Item / Issue Reference <!-- IMPORTANT: Please follow the PR template guidelines below. For mssql-python maintainers: Insert your ADO Work Item ID below (e.g. AB#37452) For external contributors: Insert Github Issue number below (e.g. #149) Only one reference is required - either GitHub issue OR ADO Work Item. --> <!-- mssql-python maintainers: ADO Work Item --> > [AB#34945](https://sqlclientdrivers.visualstudio.com/c6d89619-62de-46a0-8b46-70b92a84d85e/_workitems/edit/34945) <!-- External contributors: GitHub Issue --> > GitHub Issue: #<ISSUE_NUMBER> ------------------------------------------------------------------- ### Summary <!-- Insert your summary of changes below. Minimum 10 characters required. --> This pull request adds full support for handling Python `uuid.UUID` objects with SQL Server's `UNIQUEIDENTIFIER` type in the `mssql_python` driver. It introduces robust conversion between Python UUIDs and SQL GUIDs for both single and batch operations, ensures correct retrieval as Python `uuid.UUID` objects, and adds comprehensive tests for these scenarios. **UUID/GUID Support Improvements:** * Added mapping for Python `uuid.UUID` to SQL `GUID` types in `_map_sql_type`, enabling seamless parameter binding for UUIDs. * Implemented correct binding and conversion logic for UUIDs in both single (`BindParameters`) and batch (`BindParameterArray`) parameter bindings, including validation of binary length and error handling. [[1]](diffhunk://#diff-dde2297345718ec449a14e7dff91b7bb2342b008ecc071f562233646d71144a1L507-R534) [[2]](diffhunk://#diff-dde2297345718ec449a14e7dff91b7bb2342b008ecc071f562233646d71144a1R1911-R1948) * Enhanced data retrieval to return UUID columns as Python `uuid.UUID` objects instead of strings or raw bytes, in both single-row (`SQLGetData_wrap`) and batch (`FetchBatchData`) fetches. [[1]](diffhunk://#diff-dde2297345718ec449a14e7dff91b7bb2342b008ecc071f562233646d71144a1L2556-R2641) [[2]](diffhunk://#diff-dde2297345718ec449a14e7dff91b7bb2342b008ecc071f562233646d71144a1L2960-R3041) **Testing Enhancements:** * Added comprehensive tests for inserting, selecting, and batch operations involving UUIDs, including cases with `None` (NULL) values and mixed UUID/NULL batches. * Updated test imports to include the `uuid` module for use in new test cases. <!-- ### PR Title Guide > For feature requests FEAT: (short-description) > For non-feature requests like test case updates, config updates , dependency updates etc CHORE: (short-description) > For Fix requests FIX: (short-description) > For doc update requests DOC: (short-description) > For Formatting, indentation, or styling update STYLE: (short-description) > For Refactor, without any feature changes REFACTOR: (short-description) > For release related changes, without any feature changes RELEASE: #<RELEASE_VERSION> (short-description) ### Contribution Guidelines External contributors: - Create a GitHub issue first: https://github.com/microsoft/mssql-python/issues/new - Link the GitHub issue in the "GitHub Issue" section above - Follow the PR title format and provide a meaningful summary mssql-python maintainers: - Create an ADO Work Item following internal processes - Link the ADO Work Item in the "ADO Work Item" section above - Follow the PR title format and provide a meaningful summary -->
1 parent 7b91e8f commit 7598b35

File tree

2 files changed

+192
-0
lines changed

2 files changed

+192
-0
lines changed

mssql_python/pybind/ddbc_bindings.cpp

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2013,6 +2013,49 @@ SQLRETURN BindParameterArray(SQLHANDLE hStmt,
20132013
bufferLength = sizeof(SQL_NUMERIC_STRUCT);
20142014
break;
20152015
}
2016+
case SQL_C_GUID: {
2017+
SQLGUID* guidArray = AllocateParamBufferArray<SQLGUID>(tempBuffers, paramSetSize);
2018+
strLenOrIndArray = AllocateParamBufferArray<SQLLEN>(tempBuffers, paramSetSize);
2019+
2020+
static py::module_ uuid_mod = py::module_::import("uuid");
2021+
static py::object uuid_class = uuid_mod.attr("UUID");
2022+
for (size_t i = 0; i < paramSetSize; ++i) {
2023+
const py::handle& element = columnValues[i];
2024+
std::array<unsigned char, 16> uuid_bytes;
2025+
if (element.is_none()) {
2026+
std::memset(&guidArray[i], 0, sizeof(SQLGUID));
2027+
strLenOrIndArray[i] = SQL_NULL_DATA;
2028+
continue;
2029+
}
2030+
else if (py::isinstance<py::bytes>(element)) {
2031+
py::bytes b = element.cast<py::bytes>();
2032+
if (PyBytes_GET_SIZE(b.ptr()) != 16) {
2033+
ThrowStdException("UUID binary data must be exactly 16 bytes long.");
2034+
}
2035+
std::memcpy(uuid_bytes.data(), PyBytes_AS_STRING(b.ptr()), 16);
2036+
}
2037+
else if (py::isinstance(element, uuid_class)) {
2038+
py::bytes b = element.attr("bytes_le").cast<py::bytes>();
2039+
std::memcpy(uuid_bytes.data(), PyBytes_AS_STRING(b.ptr()), 16);
2040+
}
2041+
else {
2042+
ThrowStdException(MakeParamMismatchErrorStr(info.paramCType, paramIndex));
2043+
}
2044+
guidArray[i].Data1 = (static_cast<uint32_t>(uuid_bytes[3]) << 24) |
2045+
(static_cast<uint32_t>(uuid_bytes[2]) << 16) |
2046+
(static_cast<uint32_t>(uuid_bytes[1]) << 8) |
2047+
(static_cast<uint32_t>(uuid_bytes[0]));
2048+
guidArray[i].Data2 = (static_cast<uint16_t>(uuid_bytes[5]) << 8) |
2049+
(static_cast<uint16_t>(uuid_bytes[4]));
2050+
guidArray[i].Data3 = (static_cast<uint16_t>(uuid_bytes[7]) << 8) |
2051+
(static_cast<uint16_t>(uuid_bytes[6]));
2052+
std::memcpy(guidArray[i].Data4, uuid_bytes.data() + 8, 8);
2053+
strLenOrIndArray[i] = sizeof(SQLGUID);
2054+
}
2055+
dataPtr = guidArray;
2056+
bufferLength = sizeof(SQLGUID);
2057+
break;
2058+
}
20162059
default: {
20172060
ThrowStdException("BindParameterArray: Unsupported C type: " + std::to_string(info.paramCType));
20182061
}
@@ -3229,6 +3272,11 @@ SQLRETURN FetchBatchData(SQLHSTMT hStmt, ColumnBuffers& buffers, py::list& colum
32293272
break;
32303273
}
32313274
case SQL_GUID: {
3275+
SQLLEN indicator = buffers.indicators[col - 1][i];
3276+
if (indicator == SQL_NULL_DATA) {
3277+
row.append(py::none());
3278+
break;
3279+
}
32323280
SQLGUID* guidValue = &buffers.guidBuffers[col - 1][i];
32333281
uint8_t reordered[16];
32343282
reordered[0] = ((char*)&guidValue->Data1)[3];

tests/test_004_cursor.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7246,6 +7246,97 @@ def test_extreme_uuids(cursor, db_connection):
72467246
cursor.execute(f"DROP TABLE IF EXISTS {table_name}")
72477247
db_connection.commit()
72487248

7249+
def test_executemany_uuid_insert_and_select(cursor, db_connection):
7250+
"""Test inserting multiple UUIDs using executemany and verifying retrieval."""
7251+
table_name = "#pytest_uuid_executemany"
7252+
7253+
try:
7254+
# Drop and create a temporary table for the test
7255+
cursor.execute(f"DROP TABLE IF EXISTS {table_name}")
7256+
cursor.execute(f"""
7257+
CREATE TABLE {table_name} (
7258+
id UNIQUEIDENTIFIER PRIMARY KEY,
7259+
description NVARCHAR(50)
7260+
)
7261+
""")
7262+
db_connection.commit()
7263+
7264+
# Generate data for insertion
7265+
data_to_insert = [(uuid.uuid4(), f"Item {i}") for i in range(5)]
7266+
7267+
# Insert all data with a single call to executemany
7268+
sql = f"INSERT INTO {table_name} (id, description) VALUES (?, ?)"
7269+
cursor.executemany(sql, data_to_insert)
7270+
db_connection.commit()
7271+
7272+
# Verify the number of rows inserted
7273+
assert cursor.rowcount == 5, f"Expected 5 rows inserted, but got {cursor.rowcount}"
7274+
7275+
# Fetch all data from the table
7276+
cursor.execute(f"SELECT id, description FROM {table_name} ORDER BY description")
7277+
rows = cursor.fetchall()
7278+
7279+
# Verify the number of fetched rows
7280+
assert len(rows) == len(data_to_insert), "Number of fetched rows does not match."
7281+
7282+
# Compare inserted and retrieved rows by index
7283+
for i, (retrieved_uuid, retrieved_desc) in enumerate(rows):
7284+
expected_uuid, expected_desc = data_to_insert[i]
7285+
7286+
# Assert the type is correct
7287+
if isinstance(retrieved_uuid, str):
7288+
retrieved_uuid = uuid.UUID(retrieved_uuid) # convert if driver returns str
7289+
7290+
assert isinstance(retrieved_uuid, uuid.UUID), f"Expected uuid.UUID, got {type(retrieved_uuid)}"
7291+
assert retrieved_uuid == expected_uuid, f"UUID mismatch for '{retrieved_desc}': expected {expected_uuid}, got {retrieved_uuid}"
7292+
assert retrieved_desc == expected_desc, f"Description mismatch: expected {expected_desc}, got {retrieved_desc}"
7293+
7294+
finally:
7295+
# Clean up the temporary table
7296+
cursor.execute(f"DROP TABLE IF EXISTS {table_name}")
7297+
db_connection.commit()
7298+
7299+
def test_executemany_uuid_roundtrip_fixed_value(cursor, db_connection):
7300+
"""Ensure a fixed canonical UUID round-trips exactly."""
7301+
table_name = "#pytest_uuid_fixed"
7302+
try:
7303+
cursor.execute(f"DROP TABLE IF EXISTS {table_name}")
7304+
cursor.execute(f"""
7305+
CREATE TABLE {table_name} (
7306+
id UNIQUEIDENTIFIER,
7307+
description NVARCHAR(50)
7308+
)
7309+
""")
7310+
db_connection.commit()
7311+
7312+
fixed_uuid = uuid.UUID("12345678-1234-5678-1234-567812345678")
7313+
description = "FixedUUID"
7314+
7315+
# Insert via executemany
7316+
cursor.executemany(
7317+
f"INSERT INTO {table_name} (id, description) VALUES (?, ?)",
7318+
[(fixed_uuid, description)]
7319+
)
7320+
db_connection.commit()
7321+
7322+
# Fetch back
7323+
cursor.execute(f"SELECT id, description FROM {table_name} WHERE description = ?", description)
7324+
row = cursor.fetchone()
7325+
retrieved_uuid, retrieved_desc = row
7326+
7327+
# Ensure type and value are correct
7328+
if isinstance(retrieved_uuid, str):
7329+
retrieved_uuid = uuid.UUID(retrieved_uuid)
7330+
7331+
assert isinstance(retrieved_uuid, uuid.UUID)
7332+
assert retrieved_uuid == fixed_uuid
7333+
assert str(retrieved_uuid) == str(fixed_uuid)
7334+
assert retrieved_desc == description
7335+
7336+
finally:
7337+
cursor.execute(f"DROP TABLE IF EXISTS {table_name}")
7338+
db_connection.commit()
7339+
72497340
def test_decimal_separator_with_multiple_values(cursor, db_connection):
72507341
"""Test decimal separator with multiple different decimal values"""
72517342
original_separator = mssql_python.getDecimalSeparator()
@@ -10786,6 +10877,59 @@ def test_decimal_separator_calculations(cursor, db_connection):
1078610877

1078710878
# Cleanup
1078810879
cursor.execute("DROP TABLE IF EXISTS #pytest_decimal_calc_test")
10880+
db_connection.commit()
10881+
10882+
def test_executemany_with_uuids(cursor, db_connection):
10883+
"""Test inserting multiple rows with UUIDs and None using executemany."""
10884+
table_name = "#pytest_uuid_batch"
10885+
try:
10886+
cursor.execute(f"DROP TABLE IF EXISTS {table_name}")
10887+
cursor.execute(f"""
10888+
CREATE TABLE {table_name} (
10889+
id UNIQUEIDENTIFIER,
10890+
description NVARCHAR(50)
10891+
)
10892+
""")
10893+
db_connection.commit()
10894+
10895+
# Prepare test data: mix of UUIDs and None
10896+
test_data = [
10897+
[uuid.uuid4(), "Item 1"],
10898+
[uuid.uuid4(), "Item 2"],
10899+
[None, "Item 3"],
10900+
[uuid.uuid4(), "Item 4"],
10901+
[None, "Item 5"]
10902+
]
10903+
10904+
# Map descriptions to original UUIDs for O(1) lookup
10905+
uuid_map = {desc: uid for uid, desc in test_data}
10906+
10907+
# Execute batch insert
10908+
cursor.executemany(f"INSERT INTO {table_name} (id, description) VALUES (?, ?)", test_data)
10909+
cursor.connection.commit()
10910+
10911+
# Fetch and verify
10912+
cursor.execute(f"SELECT id, description FROM {table_name}")
10913+
rows = cursor.fetchall()
10914+
10915+
assert len(rows) == len(test_data), "Number of fetched rows does not match inserted rows."
10916+
10917+
for retrieved_uuid, retrieved_desc in rows:
10918+
expected_uuid = uuid_map[retrieved_desc]
10919+
10920+
if expected_uuid is None:
10921+
assert retrieved_uuid is None, f"Expected None for '{retrieved_desc}', got {retrieved_uuid}"
10922+
else:
10923+
# Convert string to UUID if needed
10924+
if isinstance(retrieved_uuid, str):
10925+
retrieved_uuid = uuid.UUID(retrieved_uuid)
10926+
10927+
assert isinstance(retrieved_uuid, uuid.UUID), f"Expected UUID, got {type(retrieved_uuid)}"
10928+
assert retrieved_uuid == expected_uuid, f"UUID mismatch for '{retrieved_desc}'"
10929+
10930+
finally:
10931+
cursor.execute(f"DROP TABLE IF EXISTS {table_name}")
10932+
db_connection.commit()
1078910933

1079010934
def test_nvarcharmax_executemany_streaming(cursor, db_connection):
1079110935
"""Streaming insert + fetch > 4k NVARCHAR(MAX) using executemany with all fetch modes."""

0 commit comments

Comments
 (0)