Skip to content

Commit 5ea230d

Browse files
Pin insert / update / delete executemany_returning flags True
dqlitedbapi's executemany accumulates per-parameter-set RETURNING rows via _ExecuteManyAccumulator, a DML-agnostic collector that works identically for INSERT, UPDATE and DELETE. SQLAlchemy's DefaultDialect surfaces insert_executemany_returning as a memoized property derived from other flags, and defaults the UPDATE / DELETE siblings to False, which blocks the compiler from issuing executemany RETURNING for those DML kinds. Pin all three locally — INSERT to guard against the derived property flipping, UPDATE / DELETE to expose the capability the wire path already supports. New integration coverage exercises each DML kind end-to-end against the test cluster; a unit test pins the three flags into DqliteDialect.__dict__ so the pin is load-bearing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2679879 commit 5ea230d

3 files changed

Lines changed: 153 additions & 0 deletions

File tree

src/sqlalchemydqlite/base.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,20 @@ class DqliteDialect(SQLiteDialect):
144144
delete_returning = True
145145
update_returning_multifrom = True
146146

147+
# Executemany-RETURNING flags. dqlitedbapi's executemany accumulates
148+
# per-parameter-set RETURNING rows via its _ExecuteManyAccumulator
149+
# so all three DML kinds can deliver the full row set in a single
150+
# call. The INSERT flag is a memoized property on DefaultDialect
151+
# (derived from ``insert_returning and use_insertmanyvalues``) — pin
152+
# explicitly so upstream drift can't silently flip it. UPDATE /
153+
# DELETE flags default to False on DefaultDialect, which blocks
154+
# SQLAlchemy from issuing executemany RETURNING even though the
155+
# wire path supports it; pin True to surface the capability.
156+
# Integration-verified in tests/integration/test_bulk_dml_returning.py.
157+
insert_executemany_returning = True
158+
update_executemany_returning = True
159+
delete_executemany_returning = True
160+
147161
# SQLite >= 3.7.11 supports multi-row INSERT VALUES, which SQLAlchemy's
148162
# insertmanyvalues optimisation depends on. Pin the flag so bulk-insert
149163
# behaviour stays stable against upstream dialect drift.
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"""Verify executemany RETURNING behaviour for INSERT / UPDATE / DELETE.
2+
3+
The SQLAlchemy dialect flags ``insert_executemany_returning``,
4+
``update_executemany_returning`` and ``delete_executemany_returning``
5+
describe whether the dialect can deliver per-parameter-set RETURNING
6+
rows in a single round-trip. These tests exercise the wire path
7+
end-to-end against the test cluster so the flags can be pinned
8+
confidently on the dialect itself.
9+
"""
10+
11+
import uuid
12+
from collections.abc import Generator
13+
14+
import pytest
15+
from sqlalchemy import (
16+
Column,
17+
Integer,
18+
MetaData,
19+
String,
20+
Table,
21+
bindparam,
22+
create_engine,
23+
delete,
24+
insert,
25+
update,
26+
)
27+
from sqlalchemy.engine import Engine
28+
29+
30+
@pytest.fixture
31+
def bulk_table(engine_url: str) -> Generator[tuple[Engine, Table]]:
32+
"""Per-test engine + freshly-named table so parallel runs don't collide."""
33+
table_name = f"bulk_returning_{uuid.uuid4().hex[:8]}"
34+
metadata = MetaData()
35+
table = Table(
36+
table_name,
37+
metadata,
38+
Column("id", Integer, primary_key=True),
39+
Column("label", String(50)),
40+
)
41+
eng = create_engine(engine_url, future=True)
42+
metadata.create_all(eng)
43+
try:
44+
yield eng, table
45+
finally:
46+
metadata.drop_all(eng)
47+
eng.dispose()
48+
49+
50+
class TestInsertExecutemanyReturning:
51+
def test_multi_row_insert_returns_each_row(self, bulk_table: tuple[Engine, Table]) -> None:
52+
engine, table = bulk_table
53+
stmt = insert(table).returning(table.c.id, table.c.label)
54+
with engine.begin() as conn:
55+
result = conn.execute(
56+
stmt,
57+
[
58+
{"id": 1, "label": "a"},
59+
{"id": 2, "label": "b"},
60+
{"id": 3, "label": "c"},
61+
],
62+
)
63+
rows = result.all()
64+
assert sorted(rows) == [(1, "a"), (2, "b"), (3, "c")]
65+
66+
67+
class TestUpdateExecutemanyReturning:
68+
def test_multi_row_update_returns_each_row(self, bulk_table: tuple[Engine, Table]) -> None:
69+
engine, table = bulk_table
70+
with engine.begin() as conn:
71+
conn.execute(
72+
insert(table),
73+
[
74+
{"id": 10, "label": "old-a"},
75+
{"id": 11, "label": "old-b"},
76+
],
77+
)
78+
79+
stmt = (
80+
update(table)
81+
.where(table.c.id == bindparam("target_id"))
82+
.values(label=bindparam("new_label"))
83+
.returning(table.c.id, table.c.label)
84+
)
85+
with engine.begin() as conn:
86+
result = conn.execute(
87+
stmt,
88+
[
89+
{"target_id": 10, "new_label": "new-a"},
90+
{"target_id": 11, "new_label": "new-b"},
91+
],
92+
)
93+
rows = result.all()
94+
assert sorted(rows) == [(10, "new-a"), (11, "new-b")]
95+
96+
97+
class TestDeleteExecutemanyReturning:
98+
def test_multi_row_delete_returns_each_row(self, bulk_table: tuple[Engine, Table]) -> None:
99+
engine, table = bulk_table
100+
with engine.begin() as conn:
101+
conn.execute(
102+
insert(table),
103+
[
104+
{"id": 20, "label": "x"},
105+
{"id": 21, "label": "y"},
106+
],
107+
)
108+
109+
stmt = (
110+
delete(table)
111+
.where(table.c.id == bindparam("target_id"))
112+
.returning(table.c.id, table.c.label)
113+
)
114+
with engine.begin() as conn:
115+
result = conn.execute(
116+
stmt,
117+
[{"target_id": 20}, {"target_id": 21}],
118+
)
119+
rows = result.all()
120+
assert sorted(rows) == [(20, "x"), (21, "y")]

tests/test_dialect.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,25 @@ def test_update_returning_multifrom_pinned_locally(self) -> None:
8686
assert DqliteDialect.update_returning_multifrom is True
8787
assert "update_returning_multifrom" in DqliteDialect.__dict__
8888

89+
def test_executemany_returning_flags_pinned_locally(self) -> None:
90+
# dqlitedbapi's executemany accumulates per-parameter-set
91+
# RETURNING rows via _ExecuteManyAccumulator. All three DML kinds
92+
# deliver the full row set in one call. Integration-verified in
93+
# tests/integration/test_bulk_dml_returning.py.
94+
#
95+
# INSERT: DefaultDialect exposes this as a memoized property
96+
# (derived from ``insert_returning and use_insertmanyvalues``) —
97+
# pinning locally ensures upstream drift can't silently flip it.
98+
# UPDATE / DELETE: DefaultDialect defaults False, which blocks
99+
# SQLAlchemy from issuing executemany RETURNING; pin True to
100+
# surface the capability.
101+
assert DqliteDialect.insert_executemany_returning is True
102+
assert DqliteDialect.update_executemany_returning is True
103+
assert DqliteDialect.delete_executemany_returning is True
104+
assert "insert_executemany_returning" in DqliteDialect.__dict__
105+
assert "update_executemany_returning" in DqliteDialect.__dict__
106+
assert "delete_executemany_returning" in DqliteDialect.__dict__
107+
89108
@pytest.mark.parametrize(
90109
"flag",
91110
[

0 commit comments

Comments
 (0)