diff --git a/mssql_python/pybind/ddbc_bindings.cpp b/mssql_python/pybind/ddbc_bindings.cpp index 72951246..06cf32da 100644 --- a/mssql_python/pybind/ddbc_bindings.cpp +++ b/mssql_python/pybind/ddbc_bindings.cpp @@ -2013,6 +2013,49 @@ SQLRETURN BindParameterArray(SQLHANDLE hStmt, bufferLength = sizeof(SQL_NUMERIC_STRUCT); break; } + case SQL_C_GUID: { + SQLGUID* guidArray = AllocateParamBufferArray(tempBuffers, paramSetSize); + strLenOrIndArray = AllocateParamBufferArray(tempBuffers, paramSetSize); + + static py::module_ uuid_mod = py::module_::import("uuid"); + static py::object uuid_class = uuid_mod.attr("UUID"); + for (size_t i = 0; i < paramSetSize; ++i) { + const py::handle& element = columnValues[i]; + std::array uuid_bytes; + if (element.is_none()) { + std::memset(&guidArray[i], 0, sizeof(SQLGUID)); + strLenOrIndArray[i] = SQL_NULL_DATA; + continue; + } + else if (py::isinstance(element)) { + py::bytes b = element.cast(); + if (PyBytes_GET_SIZE(b.ptr()) != 16) { + ThrowStdException("UUID binary data must be exactly 16 bytes long."); + } + std::memcpy(uuid_bytes.data(), PyBytes_AS_STRING(b.ptr()), 16); + } + else if (py::isinstance(element, uuid_class)) { + py::bytes b = element.attr("bytes_le").cast(); + std::memcpy(uuid_bytes.data(), PyBytes_AS_STRING(b.ptr()), 16); + } + else { + ThrowStdException(MakeParamMismatchErrorStr(info.paramCType, paramIndex)); + } + guidArray[i].Data1 = (static_cast(uuid_bytes[3]) << 24) | + (static_cast(uuid_bytes[2]) << 16) | + (static_cast(uuid_bytes[1]) << 8) | + (static_cast(uuid_bytes[0])); + guidArray[i].Data2 = (static_cast(uuid_bytes[5]) << 8) | + (static_cast(uuid_bytes[4])); + guidArray[i].Data3 = (static_cast(uuid_bytes[7]) << 8) | + (static_cast(uuid_bytes[6])); + std::memcpy(guidArray[i].Data4, uuid_bytes.data() + 8, 8); + strLenOrIndArray[i] = sizeof(SQLGUID); + } + dataPtr = guidArray; + bufferLength = sizeof(SQLGUID); + break; + } default: { ThrowStdException("BindParameterArray: Unsupported C type: " + std::to_string(info.paramCType)); } @@ -3229,6 +3272,11 @@ SQLRETURN FetchBatchData(SQLHSTMT hStmt, ColumnBuffers& buffers, py::list& colum break; } case SQL_GUID: { + SQLLEN indicator = buffers.indicators[col - 1][i]; + if (indicator == SQL_NULL_DATA) { + row.append(py::none()); + break; + } SQLGUID* guidValue = &buffers.guidBuffers[col - 1][i]; uint8_t reordered[16]; reordered[0] = ((char*)&guidValue->Data1)[3]; diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index 9c54ae28..bcf97165 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -7246,6 +7246,97 @@ def test_extreme_uuids(cursor, db_connection): cursor.execute(f"DROP TABLE IF EXISTS {table_name}") db_connection.commit() +def test_executemany_uuid_insert_and_select(cursor, db_connection): + """Test inserting multiple UUIDs using executemany and verifying retrieval.""" + table_name = "#pytest_uuid_executemany" + + try: + # Drop and create a temporary table for the test + cursor.execute(f"DROP TABLE IF EXISTS {table_name}") + cursor.execute(f""" + CREATE TABLE {table_name} ( + id UNIQUEIDENTIFIER PRIMARY KEY, + description NVARCHAR(50) + ) + """) + db_connection.commit() + + # Generate data for insertion + data_to_insert = [(uuid.uuid4(), f"Item {i}") for i in range(5)] + + # Insert all data with a single call to executemany + sql = f"INSERT INTO {table_name} (id, description) VALUES (?, ?)" + cursor.executemany(sql, data_to_insert) + db_connection.commit() + + # Verify the number of rows inserted + assert cursor.rowcount == 5, f"Expected 5 rows inserted, but got {cursor.rowcount}" + + # Fetch all data from the table + cursor.execute(f"SELECT id, description FROM {table_name} ORDER BY description") + rows = cursor.fetchall() + + # Verify the number of fetched rows + assert len(rows) == len(data_to_insert), "Number of fetched rows does not match." + + # Compare inserted and retrieved rows by index + for i, (retrieved_uuid, retrieved_desc) in enumerate(rows): + expected_uuid, expected_desc = data_to_insert[i] + + # Assert the type is correct + if isinstance(retrieved_uuid, str): + retrieved_uuid = uuid.UUID(retrieved_uuid) # convert if driver returns str + + assert isinstance(retrieved_uuid, uuid.UUID), f"Expected uuid.UUID, got {type(retrieved_uuid)}" + assert retrieved_uuid == expected_uuid, f"UUID mismatch for '{retrieved_desc}': expected {expected_uuid}, got {retrieved_uuid}" + assert retrieved_desc == expected_desc, f"Description mismatch: expected {expected_desc}, got {retrieved_desc}" + + finally: + # Clean up the temporary table + cursor.execute(f"DROP TABLE IF EXISTS {table_name}") + db_connection.commit() + +def test_executemany_uuid_roundtrip_fixed_value(cursor, db_connection): + """Ensure a fixed canonical UUID round-trips exactly.""" + table_name = "#pytest_uuid_fixed" + try: + cursor.execute(f"DROP TABLE IF EXISTS {table_name}") + cursor.execute(f""" + CREATE TABLE {table_name} ( + id UNIQUEIDENTIFIER, + description NVARCHAR(50) + ) + """) + db_connection.commit() + + fixed_uuid = uuid.UUID("12345678-1234-5678-1234-567812345678") + description = "FixedUUID" + + # Insert via executemany + cursor.executemany( + f"INSERT INTO {table_name} (id, description) VALUES (?, ?)", + [(fixed_uuid, description)] + ) + db_connection.commit() + + # Fetch back + cursor.execute(f"SELECT id, description FROM {table_name} WHERE description = ?", description) + row = cursor.fetchone() + retrieved_uuid, retrieved_desc = row + + # Ensure type and value are correct + if isinstance(retrieved_uuid, str): + retrieved_uuid = uuid.UUID(retrieved_uuid) + + assert isinstance(retrieved_uuid, uuid.UUID) + assert retrieved_uuid == fixed_uuid + assert str(retrieved_uuid) == str(fixed_uuid) + assert retrieved_desc == description + + finally: + cursor.execute(f"DROP TABLE IF EXISTS {table_name}") + db_connection.commit() + def test_decimal_separator_with_multiple_values(cursor, db_connection): """Test decimal separator with multiple different decimal values""" original_separator = mssql_python.getDecimalSeparator() @@ -10786,6 +10877,59 @@ def test_decimal_separator_calculations(cursor, db_connection): # Cleanup cursor.execute("DROP TABLE IF EXISTS #pytest_decimal_calc_test") + db_connection.commit() + +def test_executemany_with_uuids(cursor, db_connection): + """Test inserting multiple rows with UUIDs and None using executemany.""" + table_name = "#pytest_uuid_batch" + try: + cursor.execute(f"DROP TABLE IF EXISTS {table_name}") + cursor.execute(f""" + CREATE TABLE {table_name} ( + id UNIQUEIDENTIFIER, + description NVARCHAR(50) + ) + """) + db_connection.commit() + + # Prepare test data: mix of UUIDs and None + test_data = [ + [uuid.uuid4(), "Item 1"], + [uuid.uuid4(), "Item 2"], + [None, "Item 3"], + [uuid.uuid4(), "Item 4"], + [None, "Item 5"] + ] + + # Map descriptions to original UUIDs for O(1) lookup + uuid_map = {desc: uid for uid, desc in test_data} + + # Execute batch insert + cursor.executemany(f"INSERT INTO {table_name} (id, description) VALUES (?, ?)", test_data) + cursor.connection.commit() + + # Fetch and verify + cursor.execute(f"SELECT id, description FROM {table_name}") + rows = cursor.fetchall() + + assert len(rows) == len(test_data), "Number of fetched rows does not match inserted rows." + + for retrieved_uuid, retrieved_desc in rows: + expected_uuid = uuid_map[retrieved_desc] + + if expected_uuid is None: + assert retrieved_uuid is None, f"Expected None for '{retrieved_desc}', got {retrieved_uuid}" + else: + # Convert string to UUID if needed + if isinstance(retrieved_uuid, str): + retrieved_uuid = uuid.UUID(retrieved_uuid) + + assert isinstance(retrieved_uuid, uuid.UUID), f"Expected UUID, got {type(retrieved_uuid)}" + assert retrieved_uuid == expected_uuid, f"UUID mismatch for '{retrieved_desc}'" + + finally: + cursor.execute(f"DROP TABLE IF EXISTS {table_name}") + db_connection.commit() def test_nvarcharmax_executemany_streaming(cursor, db_connection): """Streaming insert + fetch > 4k NVARCHAR(MAX) using executemany with all fetch modes."""