From 021fa7eeff6a32476d961b3f536e5afaf66216ee Mon Sep 17 00:00:00 2001 From: gargsaumya Date: Wed, 10 Jun 2026 11:40:23 +0530 Subject: [PATCH 1/6] FIX: Handle Row objects in executemany DAE fallback path (GH-629) When executemany detects DAE parameters (large strings >4000 chars for varchar(max) columns), it falls back to row-by-row execution. This fallback was passing Row objects directly to execute(), but _map_sql_type() only recognizes primitive types (str, int, etc.), causing TypeError. Fix: Convert Row objects to tuples before passing to execute() in the DAE fallback path. This preserves compatibility while allowing Row objects from fetch APIs to be used directly with executemany. Root cause: This bug was introduced when Row objects were added in v0.10.0 (commit 54b649b4, PR #75, June 2025) and fetch APIs changed from returning tuples to returning Row objects. The DAE fallback path was not updated to handle the new Row type. Fixes #629 --- mssql_python/cursor.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mssql_python/cursor.py b/mssql_python/cursor.py index 244afa9c..bbb3ab4a 100644 --- a/mssql_python/cursor.py +++ b/mssql_python/cursor.py @@ -2413,6 +2413,11 @@ def executemany( # pylint: disable=too-many-locals,too-many-branches,too-many-s "DAE parameters detected. Falling back to row-by-row execution with streaming.", ) for row in seq_of_parameters: + # Convert Row objects to tuples for compatibility with execute() + # Row objects from fetch APIs must be converted since _map_sql_type + # only recognizes primitive types (str, int, etc.), not Row objects + if hasattr(row, "_values") and hasattr(row, "__iter__"): + row = tuple(row) self.execute(operation, row) return From ad9b4a8a73458b78254186543d3b0c1c470ca77f Mon Sep 17 00:00:00 2001 From: gargsaumya Date: Wed, 10 Jun 2026 12:21:18 +0530 Subject: [PATCH 2/6] Fix GH-629: Handle Row objects in executemany DAE fallback Addresses review comments: - Use isinstance(row, Row) for precise type checking - Update comment to accurately explain execute() unwrapping behavior When executemany() detects DAE parameters (VARCHAR(MAX) >4000 chars), it falls back to row-by-row execution. This fix converts Row objects to tuples before passing to execute(), since execute() only unwraps tuple/list/dict arguments, not Row objects. Includes regression test to prevent future issues. --- mssql_python/cursor.py | 8 ++--- tests/test_004_cursor.py | 64 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/mssql_python/cursor.py b/mssql_python/cursor.py index bbb3ab4a..61dff403 100644 --- a/mssql_python/cursor.py +++ b/mssql_python/cursor.py @@ -2413,10 +2413,10 @@ def executemany( # pylint: disable=too-many-locals,too-many-branches,too-many-s "DAE parameters detected. Falling back to row-by-row execution with streaming.", ) for row in seq_of_parameters: - # Convert Row objects to tuples for compatibility with execute() - # Row objects from fetch APIs must be converted since _map_sql_type - # only recognizes primitive types (str, int, etc.), not Row objects - if hasattr(row, "_values") and hasattr(row, "__iter__"): + # Convert Row objects to tuples so execute() can unwrap them as parameters. + # execute() only unwraps tuple/list/dict arguments, not Row objects, + # so passing a Row directly would treat it as a single scalar parameter. + if isinstance(row, Row): row = tuple(row) self.execute(operation, row) return diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index d39f42ae..9b5593ab 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -16594,3 +16594,67 @@ def test_executemany_multi_column_with_large_decimal(cursor, db_connection): finally: cursor.execute("DROP TABLE IF EXISTS #pytest_gh609_multi") db_connection.commit() + + +def test_executemany_row_objects_with_varchar_max_dae(cursor, db_connection): + """Test executemany with Row objects from fetchmany() and VARCHAR(MAX) DAE fallback (GH-629). + + When executemany() detects DAE parameters (large strings >4000 chars for VARCHAR(MAX)), + it falls back to row-by-row execution. This fallback must handle Row objects returned + by fetch APIs (fetchone/fetchmany/fetchall) by converting them to tuples before + passing to execute(). + + Without the GH-629 fix, this scenario raises: + TypeError: Unsupported parameter type: The driver cannot safely convert it to a SQL type. + + This regression test ensures Row objects from fetch APIs can be passed directly to + executemany() even when DAE fallback is triggered. + """ + try: + # Create source table with VARCHAR(MAX) column + cursor.execute(""" + CREATE TABLE #pytest_gh629_source ( + id INT, + large_text VARCHAR(MAX) + ) + """) + + # Insert data with large strings (>4000 chars triggers DAE) + large_text = "X" * 5000 + cursor.execute("INSERT INTO #pytest_gh629_source VALUES (?, ?)", (1, large_text)) + cursor.execute("INSERT INTO #pytest_gh629_source VALUES (?, ?)", (2, large_text)) + db_connection.commit() + + # Fetch rows as Row objects + cursor.execute("SELECT * FROM #pytest_gh629_source") + rows = cursor.fetchmany(10) # Returns Row objects + assert len(rows) == 2 + assert type(rows[0]).__name__ == "Row" + + # Create target table + cursor.execute(""" + CREATE TABLE #pytest_gh629_target ( + id INT, + large_text VARCHAR(MAX) + ) + """) + + # executemany with Row objects should work (triggers DAE + row-by-row fallback) + cursor.executemany("INSERT INTO #pytest_gh629_target VALUES (?, ?)", rows) + db_connection.commit() + + # Verify data was inserted correctly + cursor.execute("SELECT COUNT(*) FROM #pytest_gh629_target") + assert cursor.fetchone()[0] == 2 + + cursor.execute("SELECT id, LEN(large_text) FROM #pytest_gh629_target ORDER BY id") + result_rows = cursor.fetchall() + assert result_rows[0][0] == 1 + assert result_rows[0][1] == 5000 + assert result_rows[1][0] == 2 + assert result_rows[1][1] == 5000 + finally: + cursor.execute("DROP TABLE IF EXISTS #pytest_gh629_source") + cursor.execute("DROP TABLE IF EXISTS #pytest_gh629_target") + db_connection.commit() + From 27e1f68ee1aa056e3419d0c72212f082c3c66f00 Mon Sep 17 00:00:00 2001 From: gargsaumya Date: Wed, 10 Jun 2026 12:23:51 +0530 Subject: [PATCH 3/6] Fix Black formatting - remove trailing blank line --- tests/test_004_cursor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index 9b5593ab..cb639364 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -16657,4 +16657,3 @@ def test_executemany_row_objects_with_varchar_max_dae(cursor, db_connection): cursor.execute("DROP TABLE IF EXISTS #pytest_gh629_source") cursor.execute("DROP TABLE IF EXISTS #pytest_gh629_target") db_connection.commit() - From e87021cec711c09bcfe4a09c8cd14f60d72f97e6 Mon Sep 17 00:00:00 2001 From: gargsaumya Date: Wed, 10 Jun 2026 12:24:36 +0530 Subject: [PATCH 4/6] Simplify test docstring --- tests/test_004_cursor.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index cb639364..bf0807f5 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -16597,19 +16597,7 @@ def test_executemany_multi_column_with_large_decimal(cursor, db_connection): def test_executemany_row_objects_with_varchar_max_dae(cursor, db_connection): - """Test executemany with Row objects from fetchmany() and VARCHAR(MAX) DAE fallback (GH-629). - - When executemany() detects DAE parameters (large strings >4000 chars for VARCHAR(MAX)), - it falls back to row-by-row execution. This fallback must handle Row objects returned - by fetch APIs (fetchone/fetchmany/fetchall) by converting them to tuples before - passing to execute(). - - Without the GH-629 fix, this scenario raises: - TypeError: Unsupported parameter type: The driver cannot safely convert it to a SQL type. - - This regression test ensures Row objects from fetch APIs can be passed directly to - executemany() even when DAE fallback is triggered. - """ + """Test executemany with Row objects and VARCHAR(MAX) DAE fallback (GH-629).""" try: # Create source table with VARCHAR(MAX) column cursor.execute(""" From a5ce8f0cca1896bbd7237b3de54d2ed4989a4cc4 Mon Sep 17 00:00:00 2001 From: gargsaumya Date: Wed, 10 Jun 2026 17:35:00 +0530 Subject: [PATCH 5/6] Fix GH-629: unwrap Row params in execute() (root-cause fix) execute() only unwrapped tuple/list/dict single args, so a Row (e.g. from fetchone()) was treated as a single scalar parameter, raising 'Unsupported parameter type'. Normalize a single Row arg to a tuple at the unwrap site so both execute(sql, row) and the executemany DAE fallback work. Removes the now-redundant Row->tuple pre-patch in the DAE path. Test uses isinstance(rows[0], mssql_python.Row) instead of fragile name check. --- mssql_python/cursor.py | 11 ++++++----- tests/test_004_cursor.py | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/mssql_python/cursor.py b/mssql_python/cursor.py index 61dff403..0af9ff32 100644 --- a/mssql_python/cursor.py +++ b/mssql_python/cursor.py @@ -1454,6 +1454,12 @@ def execute( # pylint: disable=too-many-locals,too-many-branches,too-many-state if isinstance(parameters, tuple) and len(parameters) == 1: if isinstance(parameters[0], (tuple, list, dict)): actual_params = parameters[0] + elif isinstance(parameters[0], Row): + # A Row (e.g. from fetchone()) is a sequence of column values. + # Normalize it to a tuple so the downstream binding logic, which + # only handles tuple/list/dict, can unwrap it into individual + # parameters instead of treating the whole Row as one value. + actual_params = tuple(parameters[0]) else: actual_params = parameters else: @@ -2413,11 +2419,6 @@ def executemany( # pylint: disable=too-many-locals,too-many-branches,too-many-s "DAE parameters detected. Falling back to row-by-row execution with streaming.", ) for row in seq_of_parameters: - # Convert Row objects to tuples so execute() can unwrap them as parameters. - # execute() only unwraps tuple/list/dict arguments, not Row objects, - # so passing a Row directly would treat it as a single scalar parameter. - if isinstance(row, Row): - row = tuple(row) self.execute(operation, row) return diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index bf0807f5..50a3f323 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -16617,7 +16617,7 @@ def test_executemany_row_objects_with_varchar_max_dae(cursor, db_connection): cursor.execute("SELECT * FROM #pytest_gh629_source") rows = cursor.fetchmany(10) # Returns Row objects assert len(rows) == 2 - assert type(rows[0]).__name__ == "Row" + assert isinstance(rows[0], mssql_python.Row) # Create target table cursor.execute(""" From 7f5afd2e07ef7168b312b15e002c53f675e538ff Mon Sep 17 00:00:00 2001 From: gargsaumya Date: Thu, 11 Jun 2026 10:36:10 +0530 Subject: [PATCH 6/6] Add direct test for execute(sql, row) with Row params (GH-629) The GH-629 fix lives in execute()'s single-arg unwrap, but the existing test only exercised the executemany DAE path. Add a test that passes a Row directly to execute(sql, row) for both the regular bind path and the VARCHAR(MAX) DAE path, guarding the fix surface against future refactors. --- tests/test_004_cursor.py | 62 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index 50a3f323..a493cd9a 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -16645,3 +16645,65 @@ def test_executemany_row_objects_with_varchar_max_dae(cursor, db_connection): cursor.execute("DROP TABLE IF EXISTS #pytest_gh629_source") cursor.execute("DROP TABLE IF EXISTS #pytest_gh629_target") db_connection.commit() + + +def test_execute_with_row_object_as_parameters(cursor, db_connection): + """Test execute(sql, row) directly with a Row object as parameters (GH-629). + + The fix for GH-629 lives in execute()'s single-argument unwrap: a Row + (e.g. from fetchone()) must be unwrapped into individual parameters + instead of being treated as one scalar value. This guards that surface + directly so a future refactor of the unwrap logic can't silently re-break it. + """ + try: + cursor.execute(""" + CREATE TABLE #pytest_gh629_exec_source ( + id INT, + name VARCHAR(50), + large_text VARCHAR(MAX) + ) + """) + cursor.execute(""" + CREATE TABLE #pytest_gh629_exec_target ( + id INT, + name VARCHAR(50), + large_text VARCHAR(MAX) + ) + """) + + # Row 1 stays small (regular bind path); Row 2 has a >4000 char value (DAE path) + small_text = "hello" + large_text = "X" * 5000 + cursor.execute( + "INSERT INTO #pytest_gh629_exec_source VALUES (?, ?, ?)", (1, "alice", small_text) + ) + cursor.execute( + "INSERT INTO #pytest_gh629_exec_source VALUES (?, ?, ?)", (2, "bob", large_text) + ) + db_connection.commit() + + # Fetch as Row objects, then pass each Row directly to execute(sql, row) + cursor.execute("SELECT * FROM #pytest_gh629_exec_source ORDER BY id") + rows = cursor.fetchall() + assert isinstance(rows[0], mssql_python.Row) + + for row in rows: + # Passing the Row directly (not tuple(row)) must work after the fix. + cursor.execute("INSERT INTO #pytest_gh629_exec_target VALUES (?, ?, ?)", row) + db_connection.commit() + + # Verify the round-trip preserved every value + cursor.execute( + "SELECT id, name, LEN(large_text) FROM #pytest_gh629_exec_target ORDER BY id" + ) + result_rows = cursor.fetchall() + assert result_rows[0][0] == 1 + assert result_rows[0][1] == "alice" + assert result_rows[0][2] == len(small_text) + assert result_rows[1][0] == 2 + assert result_rows[1][1] == "bob" + assert result_rows[1][2] == 5000 + finally: + cursor.execute("DROP TABLE IF EXISTS #pytest_gh629_exec_source") + cursor.execute("DROP TABLE IF EXISTS #pytest_gh629_exec_target") + db_connection.commit()