Skip to content

Commit c1debd6

Browse files
authored
FEAT: Complex data type support- uniqueidentifier (#236)
### 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 comprehensive support for Python's `uuid.UUID` type when interacting with SQL Server `UNIQUEIDENTIFIER` columns. It updates both the parameter binding and result retrieval logic to handle UUIDs as native Python objects, ensuring correct type mapping, byte order, and conversion in both directions. Additionally, new tests are introduced to verify correct insertion and retrieval of UUID values, including edge cases with `None`. **UUID/UNIQUEIDENTIFIER Support Improvements:** * Added logic in `mssql_python/cursor.py` to map Python `uuid.UUID` objects to the correct SQL types and convert them to raw bytes before sending to SQL Server. [[1]](diffhunk://#diff-deceea46ae01082ce8400e14fa02f4b7585afb7b5ed9885338b66494f5f38280R429-R437) [[2]](diffhunk://#diff-deceea46ae01082ce8400e14fa02f4b7585afb7b5ed9885338b66494f5f38280R777-R778) * Updated the C++ binding layer (`mssql_python/pybind/ddbc_bindings.cpp`) to correctly marshal UUIDs between Python and SQL Server, including proper byte order conversion and error handling for incorrect UUID sizes. * Enhanced result fetching in the C++ layer to convert SQL Server `UNIQUEIDENTIFIER` columns back into Python `uuid.UUID` objects, both for single-row and batch fetches, and to handle `NULL` values gracefully. [[1]](diffhunk://#diff-dde2297345718ec449a14e7dff91b7bb2342b008ecc071f562233646d71144a1L2202-R2249) [[2]](diffhunk://#diff-dde2297345718ec449a14e7dff91b7bb2342b008ecc071f562233646d71144a1L2593-R2639) **Testing Enhancements:** * Added new tests in `tests/test_004_cursor.py` to verify correct insertion, retrieval, and handling of `None` values in UUID columns, as well as round-trip integrity for multiple UUIDs. [[1]](diffhunk://#diff-82594712308ff34afa8b067af67db231e9a1372ef474da3db121e14e4d418f69R6827-R6923) [[2]](diffhunk://#diff-82594712308ff34afa8b067af67db231e9a1372ef474da3db121e14e4d418f69R17) <!-- ### 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 d94b518 commit c1debd6

File tree

3 files changed

+293
-19
lines changed

3 files changed

+293
-19
lines changed

mssql_python/cursor.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,16 @@ def _map_sql_type(self, param, parameters_list, i, min_val=None, max_val=None):
338338
parameters_list[i].scale,
339339
False,
340340
)
341+
342+
if isinstance(param, uuid.UUID):
343+
parameters_list[i] = param.bytes_le
344+
return (
345+
ddbc_sql_const.SQL_GUID.value,
346+
ddbc_sql_const.SQL_C_GUID.value,
347+
16,
348+
0,
349+
False,
350+
)
341351

342352
if isinstance(param, str):
343353
if (
@@ -352,6 +362,20 @@ def _map_sql_type(self, param, parameters_list, i, min_val=None, max_val=None):
352362
0,
353363
False,
354364
)
365+
366+
try:
367+
val = uuid.UUID(param)
368+
parameters_list[i] = val.bytes_le
369+
return (
370+
ddbc_sql_const.SQL_GUID.value,
371+
ddbc_sql_const.SQL_C_GUID.value,
372+
16,
373+
0,
374+
False
375+
)
376+
except ValueError:
377+
pass
378+
355379

356380
# Attempt to parse as date, datetime, datetime2, timestamp, smalldatetime or time
357381
if self._parse_date(param):

mssql_python/pybind/ddbc_bindings.cpp

Lines changed: 65 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -504,7 +504,33 @@ SQLRETURN BindParameters(SQLHANDLE hStmt, const py::list& params,
504504
break;
505505
}
506506
case SQL_C_GUID: {
507-
// TODO
507+
if (!py::isinstance<py::bytes>(param)) {
508+
ThrowStdException(MakeParamMismatchErrorStr(paramInfo.paramCType, paramIndex));
509+
}
510+
py::bytes uuid_bytes = param.cast<py::bytes>();
511+
const unsigned char* uuid_data = reinterpret_cast<const unsigned char*>(PyBytes_AS_STRING(uuid_bytes.ptr()));
512+
if (PyBytes_GET_SIZE(uuid_bytes.ptr()) != 16) {
513+
LOG("Invalid UUID parameter at index {}: expected 16 bytes, got {} bytes, type {}", paramIndex, PyBytes_GET_SIZE(uuid_bytes.ptr()), paramInfo.paramCType);
514+
ThrowStdException("UUID binary data must be exactly 16 bytes long.");
515+
}
516+
SQLGUID* guid_data_ptr = AllocateParamBuffer<SQLGUID>(paramBuffers);
517+
guid_data_ptr->Data1 =
518+
(static_cast<uint32_t>(uuid_data[3]) << 24) |
519+
(static_cast<uint32_t>(uuid_data[2]) << 16) |
520+
(static_cast<uint32_t>(uuid_data[1]) << 8) |
521+
(static_cast<uint32_t>(uuid_data[0]));
522+
guid_data_ptr->Data2 =
523+
(static_cast<uint16_t>(uuid_data[5]) << 8) |
524+
(static_cast<uint16_t>(uuid_data[4]));
525+
guid_data_ptr->Data3 =
526+
(static_cast<uint16_t>(uuid_data[7]) << 8) |
527+
(static_cast<uint16_t>(uuid_data[6]));
528+
std::memcpy(guid_data_ptr->Data4, &uuid_data[8], 8);
529+
dataPtr = static_cast<void*>(guid_data_ptr);
530+
bufferLength = sizeof(SQLGUID);
531+
strLenOrIndPtr = AllocateParamBuffer<SQLLEN>(paramBuffers);
532+
*strLenOrIndPtr = sizeof(SQLGUID);
533+
break;
508534
}
509535
default: {
510536
std::ostringstream errorString;
@@ -2553,20 +2579,27 @@ SQLRETURN SQLGetData_wrap(SqlHandlePtr StatementHandle, SQLUSMALLINT colCount, p
25532579
#if (ODBCVER >= 0x0350)
25542580
case SQL_GUID: {
25552581
SQLGUID guidValue;
2556-
ret = SQLGetData_ptr(hStmt, i, SQL_C_GUID, &guidValue, sizeof(guidValue), NULL);
2557-
if (SQL_SUCCEEDED(ret)) {
2558-
std::ostringstream oss;
2559-
oss << std::hex << std::setfill('0') << std::setw(8) << guidValue.Data1 << '-'
2560-
<< std::setw(4) << guidValue.Data2 << '-' << std::setw(4) << guidValue.Data3
2561-
<< '-' << std::setw(2) << static_cast<int>(guidValue.Data4[0])
2562-
<< std::setw(2) << static_cast<int>(guidValue.Data4[1]) << '-' << std::hex
2563-
<< std::setw(2) << static_cast<int>(guidValue.Data4[2]) << std::setw(2)
2564-
<< static_cast<int>(guidValue.Data4[3]) << std::setw(2)
2565-
<< static_cast<int>(guidValue.Data4[4]) << std::setw(2)
2566-
<< static_cast<int>(guidValue.Data4[5]) << std::setw(2)
2567-
<< static_cast<int>(guidValue.Data4[6]) << std::setw(2)
2568-
<< static_cast<int>(guidValue.Data4[7]);
2569-
row.append(oss.str()); // Append GUID as a string
2582+
SQLLEN indicator;
2583+
ret = SQLGetData_ptr(hStmt, i, SQL_C_GUID, &guidValue, sizeof(guidValue), &indicator);
2584+
2585+
if (SQL_SUCCEEDED(ret) && indicator != SQL_NULL_DATA) {
2586+
std::vector<char> guid_bytes(16);
2587+
guid_bytes[0] = ((char*)&guidValue.Data1)[3];
2588+
guid_bytes[1] = ((char*)&guidValue.Data1)[2];
2589+
guid_bytes[2] = ((char*)&guidValue.Data1)[1];
2590+
guid_bytes[3] = ((char*)&guidValue.Data1)[0];
2591+
guid_bytes[4] = ((char*)&guidValue.Data2)[1];
2592+
guid_bytes[5] = ((char*)&guidValue.Data2)[0];
2593+
guid_bytes[6] = ((char*)&guidValue.Data3)[1];
2594+
guid_bytes[7] = ((char*)&guidValue.Data3)[0];
2595+
std::memcpy(&guid_bytes[8], guidValue.Data4, sizeof(guidValue.Data4));
2596+
2597+
py::bytes py_guid_bytes(guid_bytes.data(), guid_bytes.size());
2598+
py::object uuid_module = py::module_::import("uuid");
2599+
py::object uuid_obj = uuid_module.attr("UUID")(py::arg("bytes")=py_guid_bytes);
2600+
row.append(uuid_obj);
2601+
} else if (indicator == SQL_NULL_DATA) {
2602+
row.append(py::none());
25702603
} else {
25712604
LOG("Error retrieving data for column - {}, data type - {}, SQLGetData return "
25722605
"code - {}. Returning NULL value instead",
@@ -2957,9 +2990,23 @@ SQLRETURN FetchBatchData(SQLHSTMT hStmt, ColumnBuffers& buffers, py::list& colum
29572990
break;
29582991
}
29592992
case SQL_GUID: {
2960-
row.append(
2961-
py::bytes(reinterpret_cast<const char*>(&buffers.guidBuffers[col - 1][i]),
2962-
sizeof(SQLGUID)));
2993+
SQLGUID* guidValue = &buffers.guidBuffers[col - 1][i];
2994+
uint8_t reordered[16];
2995+
reordered[0] = ((char*)&guidValue->Data1)[3];
2996+
reordered[1] = ((char*)&guidValue->Data1)[2];
2997+
reordered[2] = ((char*)&guidValue->Data1)[1];
2998+
reordered[3] = ((char*)&guidValue->Data1)[0];
2999+
reordered[4] = ((char*)&guidValue->Data2)[1];
3000+
reordered[5] = ((char*)&guidValue->Data2)[0];
3001+
reordered[6] = ((char*)&guidValue->Data3)[1];
3002+
reordered[7] = ((char*)&guidValue->Data3)[0];
3003+
std::memcpy(reordered + 8, guidValue->Data4, 8);
3004+
3005+
py::bytes py_guid_bytes(reinterpret_cast<char*>(reordered), 16);
3006+
py::dict kwargs;
3007+
kwargs["bytes"] = py_guid_bytes;
3008+
py::object uuid_obj = py::module_::import("uuid").attr("UUID")(**kwargs);
3009+
row.append(uuid_obj);
29633010
break;
29643011
}
29653012
case SQL_BINARY:

tests/test_004_cursor.py

Lines changed: 204 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
import decimal
1515
from contextlib import closing
1616
import mssql_python
17+
import uuid
18+
1719

1820
# Setup test table
1921
TEST_TABLE = """
@@ -6942,6 +6944,208 @@ def test_money_smallmoney_invalid_values(cursor, db_connection):
69426944
drop_table_if_exists(cursor, "dbo.money_test")
69436945
db_connection.commit()
69446946

6947+
def test_uuid_insert_and_select_none(cursor, db_connection):
6948+
"""Test inserting and retrieving None in a nullable UUID column."""
6949+
table_name = "#pytest_uuid_nullable"
6950+
try:
6951+
cursor.execute(f"DROP TABLE IF EXISTS {table_name}")
6952+
cursor.execute(f"""
6953+
CREATE TABLE {table_name} (
6954+
id UNIQUEIDENTIFIER,
6955+
name NVARCHAR(50)
6956+
)
6957+
""")
6958+
db_connection.commit()
6959+
6960+
# Insert a row with None for the UUID
6961+
cursor.execute(f"INSERT INTO {table_name} (id, name) VALUES (?, ?)", [None, "Bob"])
6962+
db_connection.commit()
6963+
6964+
# Fetch the row
6965+
cursor.execute(f"SELECT id, name FROM {table_name}")
6966+
retrieved_uuid, retrieved_name = cursor.fetchone()
6967+
6968+
# Assert correct results
6969+
assert retrieved_uuid is None, f"Expected None, got {retrieved_uuid}"
6970+
assert retrieved_name == "Bob"
6971+
finally:
6972+
cursor.execute(f"DROP TABLE IF EXISTS {table_name}")
6973+
db_connection.commit()
6974+
6975+
6976+
def test_insert_multiple_uuids(cursor, db_connection):
6977+
"""Test inserting multiple UUIDs and verifying retrieval."""
6978+
table_name = "#pytest_uuid_multiple"
6979+
try:
6980+
cursor.execute(f"DROP TABLE IF EXISTS {table_name}")
6981+
cursor.execute(f"""
6982+
CREATE TABLE {table_name} (
6983+
id UNIQUEIDENTIFIER PRIMARY KEY,
6984+
description NVARCHAR(50)
6985+
)
6986+
""")
6987+
db_connection.commit()
6988+
6989+
# Prepare test data
6990+
uuids_to_insert = {f"Item {i}": uuid.uuid4() for i in range(5)}
6991+
6992+
# Insert UUIDs and descriptions
6993+
for desc, uid in uuids_to_insert.items():
6994+
cursor.execute(f"INSERT INTO {table_name} (id, description) VALUES (?, ?)", [uid, desc])
6995+
db_connection.commit()
6996+
6997+
# Fetch all rows
6998+
cursor.execute(f"SELECT id, description FROM {table_name}")
6999+
rows = cursor.fetchall()
7000+
7001+
# Verify each fetched row
7002+
assert len(rows) == len(uuids_to_insert), "Fetched row count mismatch"
7003+
7004+
for retrieved_uuid, retrieved_desc in rows:
7005+
assert isinstance(retrieved_uuid, uuid.UUID), f"Expected uuid.UUID, got {type(retrieved_uuid)}"
7006+
expected_uuid = uuids_to_insert[retrieved_desc]
7007+
assert retrieved_uuid == expected_uuid, f"UUID mismatch for '{retrieved_desc}': expected {expected_uuid}, got {retrieved_uuid}"
7008+
finally:
7009+
cursor.execute(f"DROP TABLE IF EXISTS {table_name}")
7010+
db_connection.commit()
7011+
7012+
7013+
def test_fetchmany_uuids(cursor, db_connection):
7014+
"""Test fetching multiple UUID rows with fetchmany()."""
7015+
table_name = "#pytest_uuid_fetchmany"
7016+
try:
7017+
cursor.execute(f"DROP TABLE IF EXISTS {table_name}")
7018+
cursor.execute(f"""
7019+
CREATE TABLE {table_name} (
7020+
id UNIQUEIDENTIFIER PRIMARY KEY,
7021+
description NVARCHAR(50)
7022+
)
7023+
""")
7024+
db_connection.commit()
7025+
7026+
uuids_to_insert = {f"Item {i}": uuid.uuid4() for i in range(10)}
7027+
7028+
for desc, uid in uuids_to_insert.items():
7029+
cursor.execute(f"INSERT INTO {table_name} (id, description) VALUES (?, ?)", [uid, desc])
7030+
db_connection.commit()
7031+
7032+
cursor.execute(f"SELECT id, description FROM {table_name}")
7033+
7034+
# Fetch in batches of 3
7035+
batch_size = 3
7036+
fetched_rows = []
7037+
while True:
7038+
batch = cursor.fetchmany(batch_size)
7039+
if not batch:
7040+
break
7041+
fetched_rows.extend(batch)
7042+
7043+
# Verify all rows
7044+
assert len(fetched_rows) == len(uuids_to_insert), "Fetched row count mismatch"
7045+
for retrieved_uuid, retrieved_desc in fetched_rows:
7046+
assert isinstance(retrieved_uuid, uuid.UUID)
7047+
expected_uuid = uuids_to_insert[retrieved_desc]
7048+
assert retrieved_uuid == expected_uuid
7049+
finally:
7050+
cursor.execute(f"DROP TABLE IF EXISTS {table_name}")
7051+
db_connection.commit()
7052+
7053+
7054+
def test_uuid_insert_with_none(cursor, db_connection):
7055+
"""Test inserting None into a UUID column results in a NULL value."""
7056+
table_name = "#pytest_uuid_none"
7057+
try:
7058+
cursor.execute(f"DROP TABLE IF EXISTS {table_name}")
7059+
cursor.execute(f"""
7060+
CREATE TABLE {table_name} (
7061+
id UNIQUEIDENTIFIER,
7062+
name NVARCHAR(50)
7063+
)
7064+
""")
7065+
db_connection.commit()
7066+
7067+
cursor.execute(f"INSERT INTO {table_name} (id, name) VALUES (?, ?)", [None, "Alice"])
7068+
db_connection.commit()
7069+
7070+
cursor.execute(f"SELECT id, name FROM {table_name}")
7071+
retrieved_uuid, retrieved_name = cursor.fetchone()
7072+
7073+
assert retrieved_uuid is None, f"Expected NULL UUID, got {retrieved_uuid}"
7074+
assert retrieved_name == "Alice"
7075+
finally:
7076+
cursor.execute(f"DROP TABLE IF EXISTS {table_name}")
7077+
db_connection.commit()
7078+
7079+
def test_invalid_uuid_inserts(cursor, db_connection):
7080+
"""Test inserting invalid UUID values raises appropriate errors."""
7081+
table_name = "#pytest_uuid_invalid"
7082+
try:
7083+
cursor.execute(f"DROP TABLE IF EXISTS {table_name}")
7084+
cursor.execute(f"CREATE TABLE {table_name} (id UNIQUEIDENTIFIER)")
7085+
db_connection.commit()
7086+
7087+
invalid_values = [
7088+
"12345", # Too short
7089+
"not-a-uuid", # Not a UUID string
7090+
123456789, # Integer
7091+
12.34, # Float
7092+
object() # Arbitrary object
7093+
]
7094+
7095+
for val in invalid_values:
7096+
with pytest.raises(Exception):
7097+
cursor.execute(f"INSERT INTO {table_name} (id) VALUES (?)", [val])
7098+
db_connection.commit()
7099+
finally:
7100+
cursor.execute(f"DROP TABLE IF EXISTS {table_name}")
7101+
db_connection.commit()
7102+
7103+
def test_duplicate_uuid_inserts(cursor, db_connection):
7104+
"""Test that inserting duplicate UUIDs into a PK column raises an error."""
7105+
table_name = "#pytest_uuid_duplicate"
7106+
try:
7107+
cursor.execute(f"DROP TABLE IF EXISTS {table_name}")
7108+
cursor.execute(f"CREATE TABLE {table_name} (id UNIQUEIDENTIFIER PRIMARY KEY)")
7109+
db_connection.commit()
7110+
7111+
uid = uuid.uuid4()
7112+
cursor.execute(f"INSERT INTO {table_name} (id) VALUES (?)", [uid])
7113+
db_connection.commit()
7114+
7115+
with pytest.raises(Exception):
7116+
cursor.execute(f"INSERT INTO {table_name} (id) VALUES (?)", [uid])
7117+
db_connection.commit()
7118+
finally:
7119+
cursor.execute(f"DROP TABLE IF EXISTS {table_name}")
7120+
db_connection.commit()
7121+
7122+
def test_extreme_uuids(cursor, db_connection):
7123+
"""Test inserting extreme but valid UUIDs."""
7124+
table_name = "#pytest_uuid_extreme"
7125+
try:
7126+
cursor.execute(f"DROP TABLE IF EXISTS {table_name}")
7127+
cursor.execute(f"CREATE TABLE {table_name} (id UNIQUEIDENTIFIER)")
7128+
db_connection.commit()
7129+
7130+
extreme_uuids = [
7131+
uuid.UUID(int=0), # All zeros
7132+
uuid.UUID(int=(1 << 128) - 1), # All ones
7133+
]
7134+
7135+
for uid in extreme_uuids:
7136+
cursor.execute(f"INSERT INTO {table_name} (id) VALUES (?)", [uid])
7137+
db_connection.commit()
7138+
7139+
cursor.execute(f"SELECT id FROM {table_name}")
7140+
rows = cursor.fetchall()
7141+
fetched_uuids = [row[0] for row in rows]
7142+
7143+
for uid in extreme_uuids:
7144+
assert uid in fetched_uuids, f"Extreme UUID {uid} not retrieved correctly"
7145+
finally:
7146+
cursor.execute(f"DROP TABLE IF EXISTS {table_name}")
7147+
db_connection.commit()
7148+
69457149
def test_decimal_separator_with_multiple_values(cursor, db_connection):
69467150
"""Test decimal separator with multiple different decimal values"""
69477151
original_separator = mssql_python.getDecimalSeparator()
@@ -10193,7 +10397,6 @@ def test_decimal_separator_calculations(cursor, db_connection):
1019310397

1019410398
# Cleanup
1019510399
cursor.execute("DROP TABLE IF EXISTS #pytest_decimal_calc_test")
10196-
db_connection.commit()
1019710400

1019810401
def test_close(db_connection):
1019910402
"""Test closing the cursor"""

0 commit comments

Comments
 (0)