Skip to content

Commit e1e004e

Browse files
Aggregate executemany rowcount across parameter sets (#784)
Address PR review: executemany looped execute() per parameter set and, since each execute() resets rowcount, left rowcount equal to only the final set's affected-row count. Per PEP 249, rowcount after executemany should reflect the total across all operations. Accumulate the reported per-statement counts; if no statement reports a count (all SELECT / the server reports none), rowcount stays at its -1 default. Adds unit tests for the sum, mixed reported/unreported, and all-unreported cases, and extends the live e2e test to assert executemany aggregation. Signed-off-by: Vikrant Puppala <vikrant.puppala@databricks.com>
1 parent 68d57d3 commit e1e004e

4 files changed

Lines changed: 94 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
# Unreleased
44
- Fix: `REMOVE` staging operations no longer require `staging_allowed_local_path` to be set, since removing a remote file does not touch the local filesystem (databricks/databricks-sql-python#726)
5-
- Report `cursor.rowcount` for DML on the Thrift backend: INSERT/UPDATE/DELETE/MERGE now set `rowcount` to the server's affected-row count instead of the hardcoded `-1`; SELECT (and statements the server does not report a count for) still return `-1` ([#784](https://github.com/databricks/databricks-sql-python/issues/784))
5+
- Report `cursor.rowcount` for DML on the Thrift backend: INSERT/UPDATE/DELETE/MERGE now set `rowcount` to the server's affected-row count instead of the hardcoded `-1`; SELECT (and statements the server does not report a count for) still return `-1`. `executemany` aggregates the count across all parameter sets per PEP 249 ([#784](https://github.com/databricks/databricks-sql-python/issues/784))
66

77
# 4.3.0 (2026-06-12)
88
- **New: optional Rust kernel backend (`use_kernel=True`).** Adds an alternative connection path backed by the native [`databricks-sql-kernel`](https://pypi.org/project/databricks-sql-kernel/) client (a Rust core exposed via PyO3), installable with the new `databricks-sql-connector[kernel]` extra. The kernel talks to Databricks over the **SEA (Statement Execution API) HTTP transport** — not Thrift — with CloudFetch and inline-Arrow result fetching, so `use_kernel=True` gives you a modern SEA-native client through the same DB-API surface. Supports PAT, OAuth M2M, and OAuth U2M auth. Requires Python >= 3.10 (the kernel wheel is `cp310-abi3`); on older interpreters the extra is a no-op and `use_kernel=True` raises a clear `ImportError`. The default backend remains Thrift — opt in per connection.

src/databricks/sql/client.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1525,8 +1525,22 @@ def executemany(
15251525
15261526
:returns self
15271527
"""
1528+
# Per PEP 249, rowcount after executemany reflects the total rows
1529+
# affected across all parameter sets (or -1 when undeterminable). Each
1530+
# execute() resets self.rowcount and sets it from its own statement, so
1531+
# we accumulate the reported counts here. If no statement reports a
1532+
# count (e.g. all SELECT, or the server does not report one), rowcount
1533+
# stays at its -1 default.
1534+
total_rowcount = -1
15281535
for parameters in seq_of_parameters:
15291536
self.execute(operation, parameters, query_tags=query_tags)
1537+
if self.rowcount >= 0:
1538+
total_rowcount = (
1539+
self.rowcount
1540+
if total_rowcount < 0
1541+
else total_rowcount + self.rowcount
1542+
)
1543+
self.rowcount = total_rowcount
15301544
return self
15311545

15321546
@log_latency(StatementType.METADATA)

tests/e2e/test_driver.py

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,9 @@
6565
for name in test_loader.getTestCaseNames(DecimalTestsMixin):
6666
if name.startswith("test_"):
6767
fn = getattr(DecimalTestsMixin, name)
68-
decorated = skipUnless(pysql_supports_arrow(), "Decimal tests need arrow support")(
69-
fn
70-
)
68+
decorated = skipUnless(
69+
pysql_supports_arrow(), "Decimal tests need arrow support"
70+
)(fn)
7171
setattr(DecimalTestsMixin, name, decorated)
7272

7373

@@ -152,9 +152,7 @@ def test_query_with_large_wide_result_set(self, extra_params, lz4_compression):
152152
cursor.connection.lz4_compression = lz4_compression
153153
uuids = ", ".join(["uuid() uuid{}".format(i) for i in range(cols)])
154154
cursor.execute(
155-
"SELECT id, {uuids} FROM RANGE({rows})".format(
156-
uuids=uuids, rows=rows
157-
)
155+
"SELECT id, {uuids} FROM RANGE({rows})".format(uuids=uuids, rows=rows)
158156
)
159157
assert lz4_compression == cursor.active_result_set.lz4_compressed
160158
for row_id, row in enumerate(
@@ -464,9 +462,7 @@ def test_dml_rowcount(self):
464462
try:
465463
cursor.execute("CREATE TABLE {} (n INT)".format(table_name))
466464

467-
cursor.execute(
468-
"INSERT INTO {} VALUES (1), (2), (3)".format(table_name)
469-
)
465+
cursor.execute("INSERT INTO {} VALUES (1), (2), (3)".format(table_name))
470466
assert cursor.rowcount == 3, f"INSERT rowcount {cursor.rowcount!r}"
471467

472468
cursor.execute(
@@ -481,6 +477,14 @@ def test_dml_rowcount(self):
481477
cursor.execute("SELECT * FROM {}".format(table_name))
482478
assert cursor.rowcount == -1, f"SELECT rowcount {cursor.rowcount!r}"
483479
assert len(cursor.fetchall()) == 2
480+
481+
# executemany aggregates the affected-row count across all
482+
# parameter sets (PEP 249), not just the last one.
483+
cursor.executemany(
484+
"INSERT INTO {} VALUES (%(v)s)".format(table_name),
485+
seq_of_parameters=[{"v": 10}, {"v": 20}, {"v": 30}],
486+
)
487+
assert cursor.rowcount == 3, f"executemany rowcount {cursor.rowcount!r}"
484488
finally:
485489
cursor.execute("DROP TABLE IF EXISTS {}".format(table_name))
486490

@@ -995,7 +999,13 @@ def test_timestamps_arrow(self):
995999
)
9961000
def test_multi_timestamps_arrow(self, extra_params):
9971001
with self.cursor(
998-
{"session_configuration": {"ansi_mode": False, "query_tags": "test:multi-timestamps,driver:python"}, **extra_params}
1002+
{
1003+
"session_configuration": {
1004+
"ansi_mode": False,
1005+
"query_tags": "test:multi-timestamps,driver:python",
1006+
},
1007+
**extra_params,
1008+
}
9991009
) as cursor:
10001010
query, expected = self.multi_query()
10011011
expected = [

tests/unit/test_client.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,8 @@ def test_executemany_parameter_passhthrough_and_uses_last_result_set(self):
500500
# Set is_staging_operation to False to avoid _handle_staging_operation being called
501501
for mock_rs in mock_result_set_instances:
502502
mock_rs.is_staging_operation = False
503+
# SELECT statements: no modified-row count, so rowcount stays -1.
504+
mock_rs.num_modified_rows = None
503505

504506
mock_backend = ThriftDatabricksClientMockFactory.new()
505507
mock_backend.execute_command.side_effect = mock_result_set_instances
@@ -529,6 +531,63 @@ def test_executemany_parameter_passhthrough_and_uses_last_result_set(self):
529531
"last operation",
530532
)
531533

534+
def test_executemany_rowcount_sums_dml_across_parameter_sets(self):
535+
"""executemany aggregates rowcount across all parameter sets (PEP 249),
536+
rather than reporting only the final statement's count (GH #784)."""
537+
result_sets = []
538+
for n in (2, 3, 5):
539+
rs = Mock()
540+
rs.is_staging_operation = False
541+
rs.num_modified_rows = n
542+
result_sets.append(rs)
543+
544+
mock_backend = ThriftDatabricksClientMockFactory.new()
545+
mock_backend.execute_command.side_effect = result_sets
546+
547+
cursor = client.Cursor(Mock(), mock_backend)
548+
cursor.executemany(
549+
"INSERT INTO t VALUES (%(x)s)",
550+
seq_of_parameters=[{"x": 1}, {"x": 2}, {"x": 3}],
551+
)
552+
553+
self.assertEqual(cursor.rowcount, 10)
554+
555+
def test_executemany_rowcount_ignores_unreported_statements(self):
556+
"""Parameter sets the server doesn't report a count for (num_modified_rows
557+
None) don't drag the aggregate down; only reported counts are summed."""
558+
reported = Mock()
559+
reported.is_staging_operation = False
560+
reported.num_modified_rows = 4
561+
562+
unreported = Mock()
563+
unreported.is_staging_operation = False
564+
unreported.num_modified_rows = None
565+
566+
mock_backend = ThriftDatabricksClientMockFactory.new()
567+
mock_backend.execute_command.side_effect = [reported, unreported]
568+
569+
cursor = client.Cursor(Mock(), mock_backend)
570+
cursor.executemany("...", seq_of_parameters=[{"x": 1}, {"x": 2}])
571+
572+
self.assertEqual(cursor.rowcount, 4)
573+
574+
def test_executemany_rowcount_stays_default_when_nothing_reported(self):
575+
"""All-SELECT (or all-unreported) executemany leaves rowcount at -1."""
576+
result_sets = []
577+
for _ in range(2):
578+
rs = Mock()
579+
rs.is_staging_operation = False
580+
rs.num_modified_rows = None
581+
result_sets.append(rs)
582+
583+
mock_backend = ThriftDatabricksClientMockFactory.new()
584+
mock_backend.execute_command.side_effect = result_sets
585+
586+
cursor = client.Cursor(Mock(), mock_backend)
587+
cursor.executemany("SELECT %(x)s", seq_of_parameters=[{"x": 1}, {"x": 2}])
588+
589+
self.assertEqual(cursor.rowcount, -1)
590+
532591
def test_setinputsizes_a_noop(self):
533592
cursor = client.Cursor(Mock(), Mock())
534593
cursor.setinputsizes(1)

0 commit comments

Comments
 (0)