From 82ab43e0a231554c326c93c367113c191a961f44 Mon Sep 17 00:00:00 2001 From: gargsaumya Date: Wed, 13 Aug 2025 09:59:23 +0530 Subject: [PATCH 01/11] lob support in execute --- mssql_python/cursor.py | 5 +++-- mssql_python/pybind/CMakeLists.txt | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/mssql_python/cursor.py b/mssql_python/cursor.py index 5bdeaed9..a152c9f4 100644 --- a/mssql_python/cursor.py +++ b/mssql_python/cursor.py @@ -344,10 +344,11 @@ def _map_sql_type(self, param, parameters_list, i): is_unicode = self._is_unicode_string(param) if len(param) > MAX_INLINE_CHAR: # Long strings if is_unicode: + utf16_len = len(param.encode("utf-16-le")) // 2 return ( ddbc_sql_const.SQL_WLONGVARCHAR.value, ddbc_sql_const.SQL_C_WCHAR.value, - len(param), + utf16_len, 0, True, ) @@ -382,7 +383,7 @@ def _map_sql_type(self, param, parameters_list, i): ddbc_sql_const.SQL_C_BINARY.value, len(param), 0, - False, + True, ) return ( ddbc_sql_const.SQL_BINARY.value, diff --git a/mssql_python/pybind/CMakeLists.txt b/mssql_python/pybind/CMakeLists.txt index 489dfd45..8f58b31c 100644 --- a/mssql_python/pybind/CMakeLists.txt +++ b/mssql_python/pybind/CMakeLists.txt @@ -272,7 +272,7 @@ target_compile_definitions(ddbc_bindings PRIVATE # Add warning level flags for MSVC if(MSVC) - target_compile_options(ddbc_bindings PRIVATE /W4 /WX) + target_compile_options(ddbc_bindings PRIVATE /W4 ) endif() # Add macOS-specific string conversion fix From 7a85d1ad7bd83e0fd1f75c30e7ca0cc2df5f698d Mon Sep 17 00:00:00 2001 From: gargsaumya Date: Wed, 13 Aug 2025 13:41:49 +0530 Subject: [PATCH 02/11] removing warning --- mssql_python/pybind/CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mssql_python/pybind/CMakeLists.txt b/mssql_python/pybind/CMakeLists.txt index 8f58b31c..489dfd45 100644 --- a/mssql_python/pybind/CMakeLists.txt +++ b/mssql_python/pybind/CMakeLists.txt @@ -272,7 +272,7 @@ target_compile_definitions(ddbc_bindings PRIVATE # Add warning level flags for MSVC if(MSVC) - target_compile_options(ddbc_bindings PRIVATE /W4 ) + target_compile_options(ddbc_bindings PRIVATE /W4 /WX) endif() # Add macOS-specific string conversion fix From 3a3773f853f202fe5a3530354e355132a8ced0a0 Mon Sep 17 00:00:00 2001 From: gargsaumya Date: Tue, 26 Aug 2025 23:59:15 +0530 Subject: [PATCH 03/11] resolved comments --- mssql_python/cursor.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/mssql_python/cursor.py b/mssql_python/cursor.py index a152c9f4..5bdeaed9 100644 --- a/mssql_python/cursor.py +++ b/mssql_python/cursor.py @@ -344,11 +344,10 @@ def _map_sql_type(self, param, parameters_list, i): is_unicode = self._is_unicode_string(param) if len(param) > MAX_INLINE_CHAR: # Long strings if is_unicode: - utf16_len = len(param.encode("utf-16-le")) // 2 return ( ddbc_sql_const.SQL_WLONGVARCHAR.value, ddbc_sql_const.SQL_C_WCHAR.value, - utf16_len, + len(param), 0, True, ) @@ -383,7 +382,7 @@ def _map_sql_type(self, param, parameters_list, i): ddbc_sql_const.SQL_C_BINARY.value, len(param), 0, - True, + False, ) return ( ddbc_sql_const.SQL_BINARY.value, From 5b6f77b04da3a10aaafea4d1a8f1aee70691b577 Mon Sep 17 00:00:00 2001 From: gargsaumya Date: Wed, 27 Aug 2025 00:10:42 +0530 Subject: [PATCH 04/11] resolved comments-2 --- mssql_python/cursor.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/mssql_python/cursor.py b/mssql_python/cursor.py index 5bdeaed9..404a8a78 100644 --- a/mssql_python/cursor.py +++ b/mssql_python/cursor.py @@ -233,7 +233,7 @@ def _map_sql_type(self, param, parameters_list, i): return ( ddbc_sql_const.SQL_VARCHAR.value, # TODO: Add SQLDescribeParam to get correct type ddbc_sql_const.SQL_C_DEFAULT.value, - 1, + 0, 0, False, ) @@ -513,6 +513,18 @@ def _create_parameter_types_list(self, parameter, param_info, parameters_list, i paraminfo. """ paraminfo = param_info() + # Explicit None handling + if parameter is None: + paraminfo.paramSQLType = ddbc_sql_const.SQL_VARCHAR.value + paraminfo.paramCType = ddbc_sql_const.SQL_C_CHAR.value + paraminfo.columnSize = 0 + paraminfo.decimalDigits = 0 + paraminfo.isDAE = False + paraminfo.inputOutputType = ddbc_sql_const.SQL_PARAM_INPUT.value + paraminfo.strLenOrInd = ddbc_sql_const.SQL_NULL_DATA.value + paraminfo.dataPtr = None + return paraminfo + sql_type, c_type, column_size, decimal_digits, is_dae = self._map_sql_type( parameter, parameters_list, i ) From c1234d7b5f36b4a849c169101de96aab702b3fb7 Mon Sep 17 00:00:00 2001 From: gargsaumya Date: Wed, 27 Aug 2025 00:23:05 +0530 Subject: [PATCH 05/11] resolved comments-3 --- mssql_python/cursor.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/mssql_python/cursor.py b/mssql_python/cursor.py index 404a8a78..5bdeaed9 100644 --- a/mssql_python/cursor.py +++ b/mssql_python/cursor.py @@ -233,7 +233,7 @@ def _map_sql_type(self, param, parameters_list, i): return ( ddbc_sql_const.SQL_VARCHAR.value, # TODO: Add SQLDescribeParam to get correct type ddbc_sql_const.SQL_C_DEFAULT.value, - 0, + 1, 0, False, ) @@ -513,18 +513,6 @@ def _create_parameter_types_list(self, parameter, param_info, parameters_list, i paraminfo. """ paraminfo = param_info() - # Explicit None handling - if parameter is None: - paraminfo.paramSQLType = ddbc_sql_const.SQL_VARCHAR.value - paraminfo.paramCType = ddbc_sql_const.SQL_C_CHAR.value - paraminfo.columnSize = 0 - paraminfo.decimalDigits = 0 - paraminfo.isDAE = False - paraminfo.inputOutputType = ddbc_sql_const.SQL_PARAM_INPUT.value - paraminfo.strLenOrInd = ddbc_sql_const.SQL_NULL_DATA.value - paraminfo.dataPtr = None - return paraminfo - sql_type, c_type, column_size, decimal_digits, is_dae = self._map_sql_type( parameter, parameters_list, i ) From ad2f55658bbec8225bb814b14350cda60eaa5c46 Mon Sep 17 00:00:00 2001 From: gargsaumya Date: Wed, 27 Aug 2025 00:33:41 +0530 Subject: [PATCH 06/11] resolved comments-4 --- mssql_python/cursor.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/mssql_python/cursor.py b/mssql_python/cursor.py index 5bdeaed9..e41793e1 100644 --- a/mssql_python/cursor.py +++ b/mssql_python/cursor.py @@ -437,7 +437,13 @@ def _map_sql_type(self, param, parameters_list, i): ) # For safety: unknown/unhandled Python types should not silently go to SQL - raise TypeError("Unsupported parameter type: The driver cannot safely convert it to a SQL type.") + # raise TypeError("Unsupported parameter type: The driver cannot safely convert it to a SQL type.") + return ( + ddbc_sql_const.SQL_VARCHAR.value, + ddbc_sql_const.SQL_C_CHAR.value, + len(str(param)), + 0, + ) def _initialize_cursor(self) -> None: """ From 786ebef657f3b72f4ecb78f88032ea51388611a7 Mon Sep 17 00:00:00 2001 From: gargsaumya Date: Thu, 28 Aug 2025 13:07:49 +0530 Subject: [PATCH 07/11] varcharmax streaming support in execute --- mssql_python/cursor.py | 8 +-- mssql_python/pybind/ddbc_bindings.cpp | 71 ++++++++++++++++++++------- 2 files changed, 55 insertions(+), 24 deletions(-) diff --git a/mssql_python/cursor.py b/mssql_python/cursor.py index e41793e1..5bdeaed9 100644 --- a/mssql_python/cursor.py +++ b/mssql_python/cursor.py @@ -437,13 +437,7 @@ def _map_sql_type(self, param, parameters_list, i): ) # For safety: unknown/unhandled Python types should not silently go to SQL - # raise TypeError("Unsupported parameter type: The driver cannot safely convert it to a SQL type.") - return ( - ddbc_sql_const.SQL_VARCHAR.value, - ddbc_sql_const.SQL_C_CHAR.value, - len(str(param)), - 0, - ) + raise TypeError("Unsupported parameter type: The driver cannot safely convert it to a SQL type.") def _initialize_cursor(self) -> None: """ diff --git a/mssql_python/pybind/ddbc_bindings.cpp b/mssql_python/pybind/ddbc_bindings.cpp index 8a88688a..5a2267af 100644 --- a/mssql_python/pybind/ddbc_bindings.cpp +++ b/mssql_python/pybind/ddbc_bindings.cpp @@ -225,7 +225,27 @@ SQLRETURN BindParameters(SQLHANDLE hStmt, const py::list& params, // TODO: Add more data types like money, guid, interval, TVPs etc. switch (paramInfo.paramCType) { - case SQL_C_CHAR: + case SQL_C_CHAR: { + if (!py::isinstance(param) && !py::isinstance(param) && + !py::isinstance(param)) { + ThrowStdException(MakeParamMismatchErrorStr(paramInfo.paramCType, paramIndex)); + } + if (paramInfo.isDAE) { + LOG("Parameter[{}] is marked for DAE streaming", paramIndex); + dataPtr = const_cast(reinterpret_cast(¶mInfos[paramIndex])); + strLenOrIndPtr = AllocateParamBuffer(paramBuffers); + *strLenOrIndPtr = SQL_LEN_DATA_AT_EXEC(0); + bufferLength = 0; + } else { + std::string* strParam = + AllocateParamBuffer(paramBuffers, param.cast()); + dataPtr = const_cast(static_cast(strParam->c_str())); + bufferLength = strParam->size() + 1; + strLenOrIndPtr = AllocateParamBuffer(paramBuffers); + *strLenOrIndPtr = SQL_NTS; + } + break; + } case SQL_C_BINARY: { if (!py::isinstance(param) && !py::isinstance(param) && !py::isinstance(param)) { @@ -1174,23 +1194,40 @@ SQLRETURN SQLExecute_wrap(const SqlHandlePtr statementHandle, continue; } if (py::isinstance(pyObj)) { - std::wstring wstr = pyObj.cast(); -#if defined(__APPLE__) || defined(__linux__) - auto utf16Buf = WStringToSQLWCHAR(wstr); - const char* dataPtr = reinterpret_cast(utf16Buf.data()); - size_t totalBytes = (utf16Buf.size() - 1) * sizeof(SQLWCHAR); -#else - const char* dataPtr = reinterpret_cast(wstr.data()); - size_t totalBytes = wstr.size() * sizeof(wchar_t); -#endif - const size_t chunkSize = DAE_CHUNK_SIZE; - for (size_t offset = 0; offset < totalBytes; offset += chunkSize) { - size_t len = std::min(chunkSize, totalBytes - offset); - rc = SQLPutData_ptr(hStmt, (SQLPOINTER)(dataPtr + offset), static_cast(len)); - if (!SQL_SUCCEEDED(rc)) { - LOG("SQLPutData failed at offset {} of {}", offset, totalBytes); - return rc; + if (matchedInfo->paramCType == SQL_C_WCHAR) { + std::wstring wstr = pyObj.cast(); + #if defined(__APPLE__) || defined(__linux__) + auto utf16Buf = WStringToSQLWCHAR(wstr); + const char* dataPtr = reinterpret_cast(utf16Buf.data()); + size_t totalBytes = (utf16Buf.size() - 1) * sizeof(SQLWCHAR); + #else + const char* dataPtr = reinterpret_cast(wstr.data()); + size_t totalBytes = wstr.size() * sizeof(wchar_t); + #endif + const size_t chunkSize = DAE_CHUNK_SIZE; + for (size_t offset = 0; offset < totalBytes; offset += chunkSize) { + size_t len = std::min(chunkSize, totalBytes - offset); + rc = SQLPutData_ptr(hStmt, (SQLPOINTER)(dataPtr + offset), static_cast(len)); + if (!SQL_SUCCEEDED(rc)) { + LOG("SQLPutData failed at offset {} of {}", offset, totalBytes); + return rc; + } } + } else if (matchedInfo->paramCType == SQL_C_CHAR) { + std::string s = pyObj.cast(); + const char* dataPtr = s.data(); + size_t totalBytes = s.size(); + const size_t chunkSize = DAE_CHUNK_SIZE; + for (size_t offset = 0; offset < totalBytes; offset += chunkSize) { + size_t len = std::min(chunkSize, totalBytes - offset); + rc = SQLPutData_ptr(hStmt, (SQLPOINTER)(dataPtr + offset), static_cast(len)); + if (!SQL_SUCCEEDED(rc)) { + LOG("SQLPutData failed at offset {} of {}", offset, totalBytes); + return rc; + } + } + } else { + ThrowStdException("Unsupported C type for str in DAE"); } } else { ThrowStdException("DAE only supported for str or bytes"); From 4d1ca3bf3d50d28d70ad9b594a3106aca5f7e06b Mon Sep 17 00:00:00 2001 From: gargsaumya Date: Fri, 29 Aug 2025 11:30:04 +0530 Subject: [PATCH 08/11] tests --- mssql_python/pybind/ddbc_bindings.cpp | 34 ++++---- tests/test_004_cursor.py | 114 ++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 17 deletions(-) diff --git a/mssql_python/pybind/ddbc_bindings.cpp b/mssql_python/pybind/ddbc_bindings.cpp index 5a2267af..6157aa55 100644 --- a/mssql_python/pybind/ddbc_bindings.cpp +++ b/mssql_python/pybind/ddbc_bindings.cpp @@ -1196,35 +1196,35 @@ SQLRETURN SQLExecute_wrap(const SqlHandlePtr statementHandle, if (py::isinstance(pyObj)) { if (matchedInfo->paramCType == SQL_C_WCHAR) { std::wstring wstr = pyObj.cast(); - #if defined(__APPLE__) || defined(__linux__) - auto utf16Buf = WStringToSQLWCHAR(wstr); - const char* dataPtr = reinterpret_cast(utf16Buf.data()); - size_t totalBytes = (utf16Buf.size() - 1) * sizeof(SQLWCHAR); - #else - const char* dataPtr = reinterpret_cast(wstr.data()); - size_t totalBytes = wstr.size() * sizeof(wchar_t); - #endif - const size_t chunkSize = DAE_CHUNK_SIZE; - for (size_t offset = 0; offset < totalBytes; offset += chunkSize) { - size_t len = std::min(chunkSize, totalBytes - offset); + size_t totalChars = wstr.size(); // number of characters + const SQLWCHAR* dataPtr = wstr.c_str(); + size_t offset = 0; + size_t chunkChars = DAE_CHUNK_SIZE / sizeof(SQLWCHAR); + while (offset < totalChars) { + size_t len = std::min(chunkChars, totalChars - offset); rc = SQLPutData_ptr(hStmt, (SQLPOINTER)(dataPtr + offset), static_cast(len)); if (!SQL_SUCCEEDED(rc)) { - LOG("SQLPutData failed at offset {} of {}", offset, totalBytes); + LOG("SQLPutData failed at offset {} of {}", offset, totalChars); return rc; } + offset += len; } - } else if (matchedInfo->paramCType == SQL_C_CHAR) { + } + else if (matchedInfo->paramCType == SQL_C_CHAR) { std::string s = pyObj.cast(); - const char* dataPtr = s.data(); size_t totalBytes = s.size(); - const size_t chunkSize = DAE_CHUNK_SIZE; - for (size_t offset = 0; offset < totalBytes; offset += chunkSize) { - size_t len = std::min(chunkSize, totalBytes - offset); + const char* dataPtr = s.data(); + size_t offset = 0; + size_t chunkBytes = DAE_CHUNK_SIZE; + while (offset < totalBytes) { + size_t len = std::min(chunkBytes, totalBytes - offset); + rc = SQLPutData_ptr(hStmt, (SQLPOINTER)(dataPtr + offset), static_cast(len)); if (!SQL_SUCCEEDED(rc)) { LOG("SQLPutData failed at offset {} of {}", offset, totalBytes); return rc; } + offset += len; } } else { ThrowStdException("Unsupported C type for str in DAE"); diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index 22149ea5..64030e5e 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -5124,6 +5124,120 @@ def test_emoji_round_trip(cursor, db_connection): except Exception as e: pytest.fail(f"Error for input {repr(text)}: {e}") +def test_varchar_max_insert_non_lob(cursor, db_connection): + """Test small VARCHAR(MAX) insert (non-LOB path).""" + try: + cursor.execute("CREATE TABLE #pytest_varchar_nonlob (col VARCHAR(MAX))") + db_connection.commit() + + small_str = "Hello, world!" # small, non-LOB + cursor.execute( + "INSERT INTO #pytest_varchar_nonlob (col) VALUES (?)", + [small_str] + ) + db_connection.commit() + + empty_str = "" + cursor.execute( + "INSERT INTO #pytest_varchar_nonlob (col) VALUES (?)", + [empty_str] + ) + db_connection.commit() + + # None value + cursor.execute( + "INSERT INTO #pytest_varchar_nonlob (col) VALUES (?)", + [None] + ) + db_connection.commit() + + # Fetch commented for now + # cursor.execute("SELECT col FROM #pytest_varchar_nonlob") + # rows = cursor.fetchall() + # assert rows == [[small_str], [empty_str], [None]] + + finally: + pass + + +def test_varchar_max_insert_lob(cursor, db_connection): + """Test large VARCHAR(MAX) insert (LOB path).""" + try: + cursor.execute("CREATE TABLE #pytest_varchar_lob (col VARCHAR(MAX))") + db_connection.commit() + + large_str = "A" * 100_000 # > 8k to trigger LOB + cursor.execute( + "INSERT INTO #pytest_varchar_lob (col) VALUES (?)", + [large_str] + ) + db_connection.commit() + + # Fetch commented for now + # cursor.execute("SELECT col FROM #pytest_varchar_lob") + # rows = cursor.fetchall() + # assert rows == [[large_str]] + + finally: + pass + + +def test_nvarchar_max_insert_non_lob(cursor, db_connection): + """Test small NVARCHAR(MAX) insert (non-LOB path).""" + try: + cursor.execute("CREATE TABLE #pytest_nvarchar_nonlob (col NVARCHAR(MAX))") + db_connection.commit() + + small_str = "Unicode ✨ test" + cursor.execute( + "INSERT INTO #pytest_nvarchar_nonlob (col) VALUES (?)", + [small_str] + ) + db_connection.commit() + + empty_str = "" + cursor.execute( + "INSERT INTO #pytest_nvarchar_nonlob (col) VALUES (?)", + [empty_str] + ) + db_connection.commit() + + cursor.execute( + "INSERT INTO #pytest_nvarchar_nonlob (col) VALUES (?)", + [None] + ) + db_connection.commit() + + # Fetch commented for now + # cursor.execute("SELECT col FROM #pytest_nvarchar_nonlob") + # rows = cursor.fetchall() + # assert rows == [[small_str], [empty_str], [None]] + + finally: + pass + + +def test_nvarchar_max_insert_lob(cursor, db_connection): + """Test large NVARCHAR(MAX) insert (LOB path).""" + try: + cursor.execute("CREATE TABLE #pytest_nvarchar_lob (col NVARCHAR(MAX))") + db_connection.commit() + + large_str = "📝" * 50_000 # each emoji = 2 UTF-16 code units, total > 100k bytes + cursor.execute( + "INSERT INTO #pytest_nvarchar_lob (col) VALUES (?)", + [large_str] + ) + db_connection.commit() + + # Fetch commented for now + # cursor.execute("SELECT col FROM #pytest_nvarchar_lob") + # rows = cursor.fetchall() + # assert rows == [[large_str]] + + finally: + pass + def test_close(db_connection): """Test closing the cursor""" From 3e3a1d1a6aa01c138949b80efd14442c39bb4d38 Mon Sep 17 00:00:00 2001 From: gargsaumya Date: Fri, 29 Aug 2025 12:21:10 +0530 Subject: [PATCH 09/11] unix encoding issue --- mssql_python/pybind/ddbc_bindings.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mssql_python/pybind/ddbc_bindings.cpp b/mssql_python/pybind/ddbc_bindings.cpp index 6157aa55..0ddf00f5 100644 --- a/mssql_python/pybind/ddbc_bindings.cpp +++ b/mssql_python/pybind/ddbc_bindings.cpp @@ -1196,8 +1196,9 @@ SQLRETURN SQLExecute_wrap(const SqlHandlePtr statementHandle, if (py::isinstance(pyObj)) { if (matchedInfo->paramCType == SQL_C_WCHAR) { std::wstring wstr = pyObj.cast(); - size_t totalChars = wstr.size(); // number of characters - const SQLWCHAR* dataPtr = wstr.c_str(); + std::vector sqlwStr = WStringToSQLWCHAR(wstr); + size_t totalChars = sqlwStr.size() - 1; + const SQLWCHAR* dataPtr = sqlwStr.data(); size_t offset = 0; size_t chunkChars = DAE_CHUNK_SIZE / sizeof(SQLWCHAR); while (offset < totalChars) { @@ -1209,8 +1210,7 @@ SQLRETURN SQLExecute_wrap(const SqlHandlePtr statementHandle, } offset += len; } - } - else if (matchedInfo->paramCType == SQL_C_CHAR) { + } else if (matchedInfo->paramCType == SQL_C_CHAR) { std::string s = pyObj.cast(); size_t totalBytes = s.size(); const char* dataPtr = s.data(); From ac9dfb028a07f9c916aa422e7f0a8b3abbd24afb Mon Sep 17 00:00:00 2001 From: gargsaumya Date: Fri, 29 Aug 2025 14:07:03 +0530 Subject: [PATCH 10/11] edge case --- mssql_python/cursor.py | 12 ++++++++---- tests/test_004_cursor.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/mssql_python/cursor.py b/mssql_python/cursor.py index 5bdeaed9..657529ad 100644 --- a/mssql_python/cursor.py +++ b/mssql_python/cursor.py @@ -342,7 +342,10 @@ def _map_sql_type(self, param, parameters_list, i): # String mapping logic here is_unicode = self._is_unicode_string(param) - if len(param) > MAX_INLINE_CHAR: # Long strings + + # Computes UTF-16 code units (handles surrogate pairs) + utf16_len = sum(2 if ord(c) > 0xFFFF else 1 for c in param) + if utf16_len > MAX_INLINE_CHAR: # Long strings -> DAE if is_unicode: return ( ddbc_sql_const.SQL_WLONGVARCHAR.value, @@ -358,8 +361,9 @@ def _map_sql_type(self, param, parameters_list, i): 0, True, ) - if is_unicode: # Short Unicode strings - utf16_len = len(param.encode("utf-16-le")) // 2 + + # Short strings + if is_unicode: return ( ddbc_sql_const.SQL_WVARCHAR.value, ddbc_sql_const.SQL_C_WCHAR.value, @@ -374,7 +378,7 @@ def _map_sql_type(self, param, parameters_list, i): 0, False, ) - + if isinstance(param, bytes): if len(param) > 8000: # Assuming VARBINARY(MAX) for long byte arrays return ( diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index 64030e5e..f6311a61 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -5238,6 +5238,46 @@ def test_nvarchar_max_insert_lob(cursor, db_connection): finally: pass +def test_nvarchar_max_boundary(cursor, db_connection): + """Test NVARCHAR(MAX) at LOB boundary sizes.""" + try: + cursor.execute("DROP TABLE IF EXISTS #pytest_nvarchar_boundary") + cursor.execute("CREATE TABLE #pytest_nvarchar_boundary (col NVARCHAR(MAX))") + db_connection.commit() + + # 4k BMP chars = 8k bytes + cursor.execute("INSERT INTO #pytest_nvarchar_boundary (col) VALUES (?)", ["A" * 4096]) + # 4k emojis = 8k UTF-16 code units (16k bytes) + cursor.execute("INSERT INTO #pytest_nvarchar_boundary (col) VALUES (?)", ["📝" * 4096]) + db_connection.commit() + + # Fetch commented for now + # cursor.execute("SELECT col FROM #pytest_nvarchar_boundary") + # rows = cursor.fetchall() + # assert rows == [["A" * 4096], ["📝" * 4096]] + finally: + pass + + +def test_nvarchar_max_chunk_edge(cursor, db_connection): + """Test NVARCHAR(MAX) insert slightly larger than a chunk.""" + try: + cursor.execute("DROP TABLE IF EXISTS #pytest_nvarchar_chunk") + cursor.execute("CREATE TABLE #pytest_nvarchar_chunk (col NVARCHAR(MAX))") + db_connection.commit() + + chunk_size = 8192 # bytes + test_str = "📝" * ((chunk_size // 4) + 3) # slightly > 1 chunk + cursor.execute("INSERT INTO #pytest_nvarchar_chunk (col) VALUES (?)", [test_str]) + db_connection.commit() + + # Fetch commented for now + # cursor.execute("SELECT col FROM #pytest_nvarchar_chunk") + # row = cursor.fetchone() + # assert row[0] == test_str + finally: + pass + def test_close(db_connection): """Test closing the cursor""" From 72c6a18d9d1cd1f76358bd605ba91b73eb5f0e9d Mon Sep 17 00:00:00 2001 From: gargsaumya Date: Mon, 1 Sep 2025 17:06:40 +0530 Subject: [PATCH 11/11] resolving commits --- mssql_python/cursor.py | 2 +- mssql_python/pybind/ddbc_bindings.cpp | 17 +++++++++++++--- tests/test_004_cursor.py | 28 ++++++++++++++++++++++++++- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/mssql_python/cursor.py b/mssql_python/cursor.py index f59a7835..e2c811c9 100644 --- a/mssql_python/cursor.py +++ b/mssql_python/cursor.py @@ -350,7 +350,7 @@ def _map_sql_type(self, param, parameters_list, i): return ( ddbc_sql_const.SQL_WLONGVARCHAR.value, ddbc_sql_const.SQL_C_WCHAR.value, - len(param), + utf16_len, 0, True, ) diff --git a/mssql_python/pybind/ddbc_bindings.cpp b/mssql_python/pybind/ddbc_bindings.cpp index b0aa32e9..d457e9cc 100644 --- a/mssql_python/pybind/ddbc_bindings.cpp +++ b/mssql_python/pybind/ddbc_bindings.cpp @@ -1225,14 +1225,25 @@ SQLRETURN SQLExecute_wrap(const SqlHandlePtr statementHandle, if (py::isinstance(pyObj)) { if (matchedInfo->paramCType == SQL_C_WCHAR) { std::wstring wstr = pyObj.cast(); + const SQLWCHAR* dataPtr = nullptr; + size_t totalChars = 0; +#if defined(__APPLE__) || defined(__linux__) std::vector sqlwStr = WStringToSQLWCHAR(wstr); - size_t totalChars = sqlwStr.size() - 1; - const SQLWCHAR* dataPtr = sqlwStr.data(); + totalChars = sqlwStr.size() - 1; + dataPtr = sqlwStr.data(); +#else + dataPtr = wstr.c_str(); + totalChars = wstr.size(); +#endif size_t offset = 0; size_t chunkChars = DAE_CHUNK_SIZE / sizeof(SQLWCHAR); while (offset < totalChars) { size_t len = std::min(chunkChars, totalChars - offset); - rc = SQLPutData_ptr(hStmt, (SQLPOINTER)(dataPtr + offset), static_cast(len)); + size_t lenBytes = len * sizeof(SQLWCHAR); + if (lenBytes > static_cast(std::numeric_limits::max())) { + ThrowStdException("Chunk size exceeds maximum allowed by SQLLEN"); + } + rc = SQLPutData_ptr(hStmt, (SQLPOINTER)(dataPtr + offset), static_cast(lenBytes)); if (!SQL_SUCCEEDED(rc)) { LOG("SQLPutData failed at offset {} of {}", offset, totalChars); return rc; diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index f6311a61..9caa9114 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -13,7 +13,7 @@ from datetime import datetime, date, time import decimal from contextlib import closing -from mssql_python import Connection +from mssql_python import Connection, row # Setup test table TEST_TABLE = """ @@ -5278,6 +5278,32 @@ def test_nvarchar_max_chunk_edge(cursor, db_connection): finally: pass +def test_empty_string_chunk(cursor, db_connection): + """Test inserting empty strings into VARCHAR(MAX) and NVARCHAR(MAX).""" + try: + cursor.execute("DROP TABLE IF EXISTS #pytest_empty_string") + cursor.execute(""" + CREATE TABLE #pytest_empty_string ( + varchar_col VARCHAR(MAX), + nvarchar_col NVARCHAR(MAX) + ) + """) + db_connection.commit() + + empty_varchar = "" + empty_nvarchar = "" + cursor.execute( + "INSERT INTO #pytest_empty_string (varchar_col, nvarchar_col) VALUES (?, ?)", + [empty_varchar, empty_nvarchar] + ) + db_connection.commit() + + cursor.execute("SELECT LEN(varchar_col), LEN(nvarchar_col) FROM #pytest_empty_string") + row = tuple(int(x) for x in cursor.fetchone()) + assert row == (0, 0), f"Expected lengths (0,0), got {row}" + finally: + cursor.execute("DROP TABLE IF EXISTS #pytest_empty_string") + db_connection.commit() def test_close(db_connection): """Test closing the cursor"""