Skip to content

Commit 543f588

Browse files
authored
Enhance executemany() to use multistatement (#798)
This pull request introduces a new `executemany_fallback` option to improve how `executemany()` handles non-bulk DML statements, allowing for client-side multi-statement batching. It also adds a new `more_results()` method to the connection API, enhances documentation, and expands test coverage for these features.
1 parent 552f7c7 commit 543f588

9 files changed

Lines changed: 936 additions & 11 deletions

File tree

‎.github/workflows/tests.yaml‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ jobs:
5353
run: |
5454
pip install -r requirements.txt
5555
56+
- if: ${{ matrix.python-version == '3.12' || matrix.mariadb }}
57+
name: Install SQLAlchemy
58+
run: |
59+
pip install "SQLAlchemy>=2,<3"
60+
5661
- name: Run tests
5762
env:
5863
TESTDB: actions.cnf
@@ -99,10 +104,11 @@ jobs:
99104
wget https://github.com/django/django/archive/${DJANGO_VERSION}.tar.gz
100105
tar xf ${DJANGO_VERSION}.tar.gz
101106
cp ci/test_mysql.py django-${DJANGO_VERSION}/tests/
107+
cp ci/test_mysql_executemany_multi.py django-${DJANGO_VERSION}/tests/
102108
cd django-${DJANGO_VERSION}
103109
pip install . -r tests/requirements/py3.txt
104110
105111
- name: Run Django test
106112
run: |
107113
cd django-${DJANGO_VERSION}/tests/
108-
PYTHONPATH=.. python3 ./runtests.py --settings=test_mysql
114+
PYTHONPATH=.. python3 ./runtests.py --settings=test_mysql_executemany_multi

‎ci/test_mysql_executemany_multi.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from test_mysql import * # noqa: F403
2+
3+
4+
for database in DATABASES.values(): # noqa: F405
5+
database.setdefault("OPTIONS", {})["executemany_fallback"] = "multi"

‎doc/user_guide.rst‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ MySQL C API function mapping
7272
``mysql_get_server_info()`` ``conn.get_server_info()``
7373
``mysql_info()`` ``conn.info()``
7474
``mysql_insert_id()`` ``conn.insert_id()``
75+
``mysql_more_results()`` ``conn.more_results()``
7576
``mysql_num_fields()`` ``result.num_fields()``
7677
``mysql_num_rows()`` ``result.num_rows()``
7778
``mysql_options()`` various options to ``_mysql.connect()``
@@ -321,6 +322,27 @@ connect(parameters...)
321322
overridden. Default: ``MySQLdb.cursors.Cursor``. *This
322323
must be a keyword parameter.*
323324

325+
executemany_fallback
326+
Controls how ``Cursor.executemany()`` handles statements that
327+
cannot use the multi-row INSERT/REPLACE optimization. ``"loop"``
328+
executes the statements one at a time and is the default.
329+
``"multi"`` may combine safe data manipulation statements into a
330+
multi-statement query. If multi-statements are disabled or a query
331+
is not eligible, execution silently falls back to ``"loop"``.
332+
333+
This is a connection option so it can be passed through, for
334+
example, SQLAlchemy's ``connect_args`` or Django's database
335+
``OPTIONS``::
336+
337+
create_engine(
338+
"mysql+mysqldb://user:password@host/database",
339+
connect_args={"executemany_fallback": "multi"},
340+
)
341+
342+
DATABASES["default"]["OPTIONS"]["executemany_fallback"] = "multi"
343+
344+
See ``executemany()`` below for batching and transaction details.
345+
324346
use_unicode
325347
If True, CHAR and VARCHAR and TEXT columns are returned as
326348
Unicode strings, using the configured character set. It is
@@ -562,6 +584,31 @@ close()
562584
close the cursor when you are done with it and before creating a
563585
new one.
564586

587+
executemany(operation, seq_of_params)
588+
Executes an operation for every parameter set and returns the total
589+
number of affected rows. Multi-row INSERT and REPLACE statements use
590+
MySQLdb's existing single-statement ``VALUES`` rewrite whenever it
591+
applies, independently of ``executemany_fallback``.
592+
593+
With ``executemany_fallback="multi"``, statements that do not match that
594+
rewrite may instead be sent in multi-statement batches. This applies only
595+
to SQL templates which begin with INSERT, REPLACE, UPDATE, or DELETE
596+
(ignoring leading whitespace), contain no semicolon, and contain no
597+
``RETURNING`` clause. Other statements, including statements beginning
598+
with a comment or ``WITH``, use the normal loop. The loop is also used
599+
silently when the connection does not have multi-statements enabled.
600+
601+
Each batch is limited to 16000 encoded bytes, including separators, and
602+
200 statements. A single rendered statement exceeding the byte limit is
603+
executed alone. On successful completion, ``rowcount`` and the return
604+
value are the sum of the affected-row counts for all statements.
605+
606+
Batching does not create an implicit transaction and is not atomic. If a
607+
statement fails, statements before it may already have executed, while
608+
statements after it do not execute. Applications needing all-or-nothing
609+
behavior must manage a transaction explicitly; with autocommit enabled,
610+
each statement may be committed independently.
611+
565612
info()
566613
Returns some information about the last query. Normally
567614
you don't need to check this. If there are any MySQL

‎src/MySQLdb/_mysql.c‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -982,6 +982,28 @@ Returns 0 if there are more results; -1 if there are no more results\n\
982982
\n\
983983
Non-standard.\n\
984984
";
985+
986+
static const char _mysql_ConnectionObject_more_results__doc__[] =
987+
"Returns True if one or more results follow the current result of a\n\
988+
multi-statement query. This check does not advance to the next result.\n\
989+
\n\
990+
Non-standard.\n\
991+
";
992+
993+
static PyObject *
994+
_mysql_ConnectionObject_more_results(
995+
_mysql_ConnectionObject *self,
996+
PyObject *noargs)
997+
{
998+
int ret;
999+
BEGIN_CONNECTION_OPERATION(self, return _mysql_Exception(self));
1000+
ret = mysql_more_results(&(self->connection));
1001+
END_CONNECTION_LOCK(self);
1002+
if (ret)
1003+
Py_RETURN_TRUE;
1004+
Py_RETURN_FALSE;
1005+
}
1006+
9851007
static PyObject *
9861008
_mysql_ConnectionObject_next_result(
9871009
_mysql_ConnectionObject *self,
@@ -2618,6 +2640,12 @@ static PyMethodDef _mysql_ConnectionObject_methods[] = {
26182640
METH_NOARGS,
26192641
_mysql_ConnectionObject_rollback__doc__
26202642
},
2643+
{
2644+
"more_results",
2645+
(PyCFunction)_mysql_ConnectionObject_more_results,
2646+
METH_NOARGS,
2647+
_mysql_ConnectionObject_more_results__doc__
2648+
},
26212649
{
26222650
"next_result",
26232651
(PyCFunction)_mysql_ConnectionObject_next_result,

‎src/MySQLdb/connections.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ class Connection(_mysql.connection):
5353
"""MySQL Database Connection Object"""
5454

5555
default_cursor = cursors.Cursor
56+
executemany_fallback = "loop"
5657

5758
def __init__(self, *args, **kwargs):
5859
"""
@@ -123,6 +124,13 @@ class object, used to create cursors (keyword only)
123124
If True, enable multi statements for clients >= 4.1.
124125
Defaults to True.
125126
127+
:param str executemany_fallback:
128+
Controls how ``Cursor.executemany()`` executes statements which
129+
cannot use the multi-row INSERT/REPLACE optimization. ``"loop"``
130+
executes each statement separately (the default), while
131+
``"multi"`` batches safe data manipulation statements into a
132+
multi-statement query when multi statements are enabled.
133+
126134
:param str ssl_mode:
127135
specify the security settings for connection to the server;
128136
see the MySQL documentation for more details
@@ -188,6 +196,13 @@ class object, used to create cursors (keyword only)
188196
use_unicode = kwargs2.pop("use_unicode", True)
189197
sql_mode = kwargs2.pop("sql_mode", "")
190198
self._binary_prefix = kwargs2.pop("binary_prefix", False)
199+
executemany_fallback = kwargs2.pop(
200+
"executemany_fallback", self.executemany_fallback
201+
)
202+
if executemany_fallback not in ("loop", "multi"):
203+
raise ValueError(
204+
"executemany_fallback must be either 'loop' or 'multi'"
205+
)
191206

192207
client_flag = kwargs.get("client_flag", 0)
193208
client_flag |= CLIENT.MULTI_RESULTS
@@ -203,6 +218,7 @@ class object, used to create cursors (keyword only)
203218
super().__init__(*args, **kwargs2)
204219

205220
self.cursorclass = cursorclass
221+
self.executemany_fallback = executemany_fallback
206222
self.encoders = {k: v for k, v in conv.items() if type(k) is not int}
207223
self._server_version = tuple(
208224
[numeric_part(n) for n in self.get_server_info().split(".")[:2]]

0 commit comments

Comments
 (0)