diff --git a/doc/user_guide.rst b/doc/user_guide.rst index 82080521..f9e43fe2 100644 --- a/doc/user_guide.rst +++ b/doc/user_guide.rst @@ -536,6 +536,47 @@ rollback() (cancels) the current transaction; otherwise a ``NotSupportedError`` is raised. +reset() + Reset the session using ``mysql_reset_connection()`` + (``COM_RESET_CONNECTION``), returning ``None`` on success. This rolls + back active transactions, drops temporary tables, releases locks and + prepared statements, and clears user variables. Session system variables, + including autocommit and SQL mode, follow the server's reset behavior. + Autocommit is **not** restored to the value passed to ``connect()``. + ``get_autocommit()`` reads the status returned by the server. + + MySQL 8.4 has a known limitation when global autocommit is OFF: reset + restores the session variable to OFF but reports ON in protocol status. + This method does not normalize that inconsistent server state. With the + usual global autocommit ON, call ``autocommit(False)`` after resetting if + needed. MariaDB's reset status agrees with its global autocommit value. + + The authenticated user, current database and connection ID are unchanged. + The current client character set and any collation explicitly configured + through ``connect()`` or ``set_character_set()`` are reapplied so that + server settings, C-library escaping and Python encoding agree. Calling + ``set_character_set()`` without a collation selects the charset's default + collation and clears any previously configured collation. Changes made + through arbitrary SQL are not tracked or preserved. ``init_command`` and + other connection initialization settings are not replayed. ``messages`` + is cleared; Python conversion and cursor configuration are retained. + + Consume every result set and close existing cursors before resetting; + create new cursors afterwards. Unread results raise ``ProgrammingError``. + Closed or lost connections raise the usual connection exceptions. Reset + never reconnects, even after ``ping(True)``, and does not fall back to + ``change_user()`` or another reset command. If reset succeeds but restoring + the charset or collation fails, the connection is closed and the error + is raised. + + Requires a server supporting ``COM_RESET_CONNECTION`` and a build using + MySQL client library 5.7.3+ or MariaDB Connector/C 3.0.0+. Older client + libraries raise ``NotSupportedError``; server errors are propagated. + See the `MySQL reset API documentation + `_ + and `MariaDB Connector/C reset API documentation + `_. + cursor([cursorclass]) MySQL does not support cursors; however, cursors are easily emulated. You can supply an alternative cursor class as an diff --git a/src/MySQLdb/_mysql.c b/src/MySQLdb/_mysql.c index 97914820..ca23b61e 100644 --- a/src/MySQLdb/_mysql.c +++ b/src/MySQLdb/_mysql.c @@ -52,6 +52,15 @@ PERFORMANCE OF THIS SOFTWARE. #define HAVE_MYSQL_OPT_LOCAL_INFILE_DIR #endif +/* MariaDB's MYSQL_VERSION_ID describes the server, not Connector/C. */ +#if defined(MARIADB_PACKAGE_VERSION_ID) +#if MARIADB_PACKAGE_VERSION_ID >= 30000 +#define HAVE_MYSQL_RESET_CONNECTION +#endif +#elif !defined(MARIADB_BASE_VERSION) && !defined(MARIADB_VERSION_ID) && MYSQL_VERSION_ID >= 50703 +#define HAVE_MYSQL_RESET_CONNECTION +#endif + #define PY_SSIZE_T_CLEAN 1 #include "Python.h" @@ -969,6 +978,113 @@ _mysql_ConnectionObject_rollback( Py_RETURN_NONE; } +static const char _mysql_ConnectionObject_reset__doc__[] = +"Reset session state without reconnecting. Returns None.\n\ +Rolls back transactions and restores server session defaults, including\n\ +autocommit. Preserves the client character set with its default collation.\n\ +Consume pending results before calling. Requires mysql_reset_connection().\n\ +"; + +static PyObject * +_mysql_ConnectionObject_Reset( + _mysql_ConnectionObject *self, + const char *collation) +{ + BEGIN_CONNECTION_OPERATION(self, return _mysql_Exception(self)); +#ifdef HAVE_MYSQL_RESET_CONNECTION + /* Connector/C can discard unread results, unlike libmysqlclient. Do not + * invalidate a live streaming result or silently discard result sets. */ + if (self->connection.status != MYSQL_STATUS_READY || + mysql_more_results(&self->connection)) { + END_CONNECTION_LOCK(self); + PyErr_SetString(_mysql_ProgrammingError, + "Consume all pending results before resetting the connection."); + return NULL; + } + + const char *charset = mysql_character_set_name(&self->connection); + PyObject *collation_query = NULL; + const char *collation_sql = NULL; + unsigned long collation_sql_len = 0; + if (collation && *collation) { + collation_query = PyBytes_FromFormat( + "SET NAMES %s COLLATE %s", charset, collation); + if (!collation_query) { + END_CONNECTION_LOCK(self); + return NULL; + } + collation_sql = PyBytes_AS_STRING(collation_query); + collation_sql_len = (unsigned long)PyBytes_GET_SIZE(collation_query); + } + int err, restore_error = 0; + my_bool reconnect = (my_bool)self->reconnect; +#ifdef MARIADB_PACKAGE_VERSION_ID + /* Connector/C option files can enable reconnect without ping(True). */ + mysql_get_option(&self->connection, MYSQL_OPT_RECONNECT, &reconnect); +#endif + /* ping(True) enables persistent automatic reconnection. Suppress it for + * both reset and charset restoration, without changing that preference. */ + if (reconnect) { + my_bool disabled = 0; + mysql_options(&self->connection, MYSQL_OPT_RECONNECT, &disabled); + } + Py_BEGIN_ALLOW_THREADS + err = mysql_reset_connection(&self->connection); + if (!err) { + /* Reset changes server charset variables but leaves the C library's + * charset unchanged. Keep escaping and Python encoding coherent. */ + err = restore_error = mysql_set_character_set(&self->connection, charset); + } + if (!err && collation_sql) { + err = restore_error = mysql_real_query( + &self->connection, collation_sql, collation_sql_len); + } + Py_END_ALLOW_THREADS + Py_XDECREF(collation_query); + PyObject *ret = err ? _mysql_Exception(self) : NULL; + if (restore_error) { + /* The reset succeeded but charset/collation restoration did not. This + * connection must not be reused with mismatched client/server state. */ + Py_BEGIN_ALLOW_THREADS + mysql_close(&self->connection); + Py_END_ALLOW_THREADS + self->open = false; + _mysql_ConnectionObject_clear(self); + } + else if (reconnect) { + mysql_options(&self->connection, MYSQL_OPT_RECONNECT, &reconnect); + } + END_CONNECTION_LOCK(self); + if (err) return ret; + Py_RETURN_NONE; +#else + END_CONNECTION_LOCK(self); + PyErr_SetString(_mysql_NotSupportedError, + "reset() requires MySQL client library >= 5.7.3 or MariaDB Connector/C >= 3.0.0"); + return NULL; +#endif +} + +static PyObject * +_mysql_ConnectionObject_reset( + _mysql_ConnectionObject *self, + PyObject *noargs) +{ + return _mysql_ConnectionObject_Reset(self, NULL); +} + +/* Used by Connection.reset() to restore its explicitly configured collation + * within the same lock and reconnect suppression as the native reset. */ +static PyObject * +_mysql_ConnectionObject_reset_with_collation( + _mysql_ConnectionObject *self, + PyObject *args) +{ + const char *collation; + if (!PyArg_ParseTuple(args, "z:_reset_with_collation", &collation)) return NULL; + return _mysql_ConnectionObject_Reset(self, collation); +} + static const char _mysql_ConnectionObject_next_result__doc__[] = "If more query results exist, next_result() reads the next query\n\ results and returns the status back to application.\n\ @@ -2622,6 +2738,18 @@ static PyMethodDef _mysql_ConnectionObject_methods[] = { METH_VARARGS, _mysql_ConnectionObject_autocommit__doc__ }, + { + "reset", + (PyCFunction)_mysql_ConnectionObject_reset, + METH_NOARGS, + _mysql_ConnectionObject_reset__doc__ + }, + { + "_reset_with_collation", + (PyCFunction)_mysql_ConnectionObject_reset_with_collation, + METH_VARARGS, + NULL + }, { "get_autocommit", (PyCFunction)_mysql_ConnectionObject_get_autocommit, diff --git a/src/MySQLdb/connections.py b/src/MySQLdb/connections.py index 98847a65..dc7fd4e3 100644 --- a/src/MySQLdb/connections.py +++ b/src/MySQLdb/connections.py @@ -200,9 +200,7 @@ class object, used to create cursors (keyword only) "executemany_fallback", self.executemany_fallback ) if executemany_fallback not in ("loop", "multi"): - raise ValueError( - "executemany_fallback must be either 'loop' or 'multi'" - ) + raise ValueError("executemany_fallback must be either 'loop' or 'multi'") client_flag = kwargs.get("client_flag", 0) client_flag |= CLIENT.MULTI_RESULTS @@ -295,6 +293,17 @@ def cursor(self, cursorclass=None): """ return (cursorclass or self.cursorclass)(self) + def reset(self): + """Reset the session without reconnecting; return None. + + Roll back transactions and restore server session defaults, including + autocommit. Keep the current client charset and explicitly set collation. + Connection initialization commands are not replayed. Consume all results + and close cursors before calling; create new cursors after the reset. + """ + super()._reset_with_collation(self._collation) + self.messages.clear() + def query(self, query): # Since _mysql releases GIL while querying, we need immutable buffer. if isinstance(query, bytearray): @@ -343,9 +352,13 @@ def set_character_set(self, charset, collation=None): """Set the connection character set to charset.""" super().set_character_set(charset) self.encoding = _charset_to_encoding.get(charset, charset) + # The native call selected the default collation, even if the optional + # collation change below fails. + self._collation = None if collation: self.query(f"SET NAMES {charset} COLLATE {collation}") self.store_result() + self._collation = collation def set_sql_mode(self, sql_mode): """Set the connection sql_mode. See MySQL documentation for diff --git a/tests/test_connection.py b/tests/test_connection.py index fecb07dd..9b407f2c 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -4,8 +4,8 @@ import pytest from configdb import connection_factory, connection_kwargs -from MySQLdb.connections import Connection from MySQLdb._exceptions import ProgrammingError +from MySQLdb.connections import Connection from MySQLdb.constants import CLIENT @@ -63,9 +63,7 @@ class MultiConnection(Connection): with MultiConnection(**connection_kwargs({})) as conn: assert conn.executemany_fallback == "multi" - with MultiConnection( - **connection_kwargs({"executemany_fallback": "loop"}) - ) as conn: + with MultiConnection(**connection_kwargs({"executemany_fallback": "loop"})) as conn: assert conn.executemany_fallback == "loop" @@ -74,7 +72,8 @@ def test_ping_false_warns(conn): conn.ping(False) -def test_connection_concurrent_use_raises(conn): +@pytest.mark.parametrize("method", ["thread_id", "reset"]) +def test_connection_concurrent_use_raises(conn, method): """While a slow query holds the connection lock, any other access from a second thread must raise ProgrammingError immediately (not block).""" thread_error = None @@ -99,7 +98,7 @@ def run_slow_query(): start = time.monotonic() with pytest.raises(ProgrammingError, match="already in use"): - conn.thread_id() + getattr(conn, method)() # Should fail immediately, not wait for the SLEEP to finish. assert time.monotonic() - start < 0.1 diff --git a/tests/test_reset.py b/tests/test_reset.py new file mode 100644 index 00000000..0f53fc66 --- /dev/null +++ b/tests/test_reset.py @@ -0,0 +1,240 @@ +"""Connection reset integration tests (requires COM_RESET_CONNECTION).""" + +from pathlib import Path + +import pytest +from configdb import connection_factory, connection_kwargs + +from MySQLdb import OperationalError, ProgrammingError, _mysql +from MySQLdb.constants import CR + + +def scalar(conn, sql): + with conn.cursor() as cursor: + cursor.execute(sql) + return cursor.fetchone()[0] + + +def test_reset_session(): + with connection_factory( + init_command="SET @reset_init = 1", sql_mode="ANSI_QUOTES" + ) as conn: + thread_id = conn.thread_id() + database = scalar(conn, "SELECT DATABASE()") + assert scalar(conn, "SELECT @reset_init") == 1 + conn.query("CREATE TEMPORARY TABLE reset_temporary (id INT)") + assert scalar(conn, "SELECT GET_LOCK('mysqlclient_reset_test', 0)") == 1 + conn.messages.append((Warning, "old message")) + + assert conn.reset() is None + assert conn.messages == [] + assert conn.thread_id() == thread_id + assert scalar(conn, "SELECT DATABASE()") == database + assert scalar(conn, "SELECT @reset_init") is None + assert scalar(conn, "SELECT @@session.sql_mode = @@global.sql_mode") == 1 + assert scalar(conn, "SELECT IS_FREE_LOCK('mysqlclient_reset_test')") == 1 + with pytest.raises(ProgrammingError) as exc: + conn.query("SELECT * FROM reset_temporary") + assert exc.value.args[0] == 1146 + assert conn.reset() is None + assert scalar(conn, "SELECT 42") == 42 + + +def test_reset_rolls_back(): + with connection_factory() as conn: + conn.query("DROP TABLE IF EXISTS reset_transaction") + conn.query("CREATE TABLE reset_transaction (id INT) ENGINE=InnoDB") + try: + conn.query("INSERT INTO reset_transaction VALUES (1)") + conn.commit() + conn.query("INSERT INTO reset_transaction VALUES (2)") + conn.reset() + assert scalar(conn, "SELECT COUNT(*) FROM reset_transaction") == 1 + conn.autocommit(False) + conn.query("INSERT INTO reset_transaction VALUES (3)") + conn.commit() + assert scalar(conn, "SELECT COUNT(*) FROM reset_transaction") == 2 + finally: + conn.query("DROP TABLE reset_transaction") + + +@pytest.mark.parametrize("autocommit", [False, True, None]) +def test_reset_autocommit(autocommit): + with connection_factory(autocommit=autocommit) as conn: + default = bool(scalar(conn, "SELECT @@global.autocommit")) + conn.autocommit(not default) + conn.reset() + # Check cached status before another command can update it. + cached = conn.get_autocommit() + assert bool(scalar(conn, "SELECT @@session.autocommit")) is default + if not default and "MariaDB" not in conn.get_server_info() and cached: + pytest.xfail("MySQL reset reports autocommit ON with global autocommit OFF") + assert cached is default + conn.autocommit(not default) + assert bool(scalar(conn, "SELECT @@session.autocommit")) is not default + + +@pytest.mark.parametrize( + "charset, collation, value", + [ + ("utf8mb4", "utf8mb4_bin", "ι›ͺπŸ˜€'\\"), + ("latin1", "latin1_bin", "ێ'\\"), + ("gbk", "gbk_bin", "δΈ­ζ–‡'\\"), + ], +) +@pytest.mark.parametrize("use_unicode", [False, True]) +def test_reset_charset(charset, collation, value, use_unicode): + with connection_factory(charset="utf8mb4", use_unicode=use_unicode) as conn: + # Preserve the current charset, even when changed since connect(). + conn.set_character_set(charset, collation) + encoding = conn.encoding + for _ in range(2): + assert conn.reset() is None + assert conn.encoding == encoding + assert conn.character_set_name() == charset + with conn.cursor() as cursor: + cursor.execute( + "SELECT @@character_set_client, @@character_set_connection, " + "@@character_set_results, @@collation_connection" + ) + client, connection, results, actual_collation = cursor.fetchone() + expected_charset = charset if use_unicode else charset.encode() + assert client == connection == results == expected_charset + assert actual_collation == ( + collation if use_unicode else collation.encode() + ) + cursor.execute("SELECT %s", (value,)) + expected = value if use_unicode else value.encode(encoding) + assert cursor.fetchone() == (expected,) + + +def test_reset_constructor_collation(): + with connection_factory(charset="utf8mb4", collation="utf8mb4_bin") as conn: + # Raw SQL does not replace the configuration retained by the driver. + conn.query("SET NAMES utf8mb4 COLLATE utf8mb4_general_ci") + for _ in range(2): + assert conn.reset() is None + assert scalar(conn, "SELECT @@collation_connection") == "utf8mb4_bin" + + +@pytest.mark.parametrize("collation", [None, ""]) +def test_reset_charset_change_clears_collation(collation): + with connection_factory(charset="utf8mb4", collation="utf8mb4_bin") as conn: + conn.set_character_set("latin1", "latin1_bin") + conn.reset() + assert scalar(conn, "SELECT @@collation_connection") == "latin1_bin" + + conn.set_character_set("utf8mb4", collation) + default = scalar(conn, "SELECT @@collation_connection") + assert default != "utf8mb4_bin" + conn.reset() + assert scalar(conn, "SELECT @@collation_connection") == default + assert conn.character_set_name() == "utf8mb4" + assert scalar(conn, "SELECT 'ι›ͺπŸ˜€'") == "ι›ͺπŸ˜€" + + +def test_reset_after_failed_collation_change(): + with connection_factory(charset="utf8mb4", collation="utf8mb4_bin") as conn: + # The charset changes before the server rejects the incompatible collation. + with pytest.raises(OperationalError) as exc: + conn.set_character_set("latin1", "utf8mb4_bin") + assert exc.value.args[0] == 1253 # ER_COLLATION_CHARSET_MISMATCH + default = scalar(conn, "SELECT @@collation_connection") + assert conn.reset() is None + assert conn.character_set_name() == "latin1" + assert scalar(conn, "SELECT @@collation_connection") == default + assert scalar(conn, "SELECT 'ێ'") == "ێ" + + +@pytest.mark.parametrize("reconnect", [False, True]) +def test_reset_collation_restoration_error_closes_connection(reconnect): + conn = connection_factory(charset="utf8mb4", collation="utf8mb4_bin") + try: + if reconnect: + with pytest.warns(DeprecationWarning): + conn.ping(True) + # Simulate the server refusing the retained collation during reset. + conn._collation = "mysqlclient_nonexistent_collation" + with pytest.raises(OperationalError) as exc: + conn.reset() + assert exc.value.args[0] == 1273 # ER_UNKNOWN_COLLATION + assert conn.closed + with pytest.raises(OperationalError) as exc: + conn.query("SELECT 1") + assert exc.value.args[0] == CR.SERVER_GONE_ERROR + finally: + if not conn.closed: + conn.close() + + +def test_reset_closed(): + conn = connection_factory() + conn.close() + for _ in range(2): + with pytest.raises(OperationalError) as exc: + conn.reset() + assert exc.value.args[0] == CR.SERVER_GONE_ERROR + + +@pytest.mark.parametrize("reconnect", [False, True]) +def test_reset_lost_connection_does_not_reconnect(reconnect): + with connection_factory() as conn, connection_factory() as killer: + if reconnect: + with pytest.warns(DeprecationWarning): + conn.ping(True) + killer.query(f"KILL CONNECTION {conn.thread_id()}") + for _ in range(2): + with pytest.raises(OperationalError) as exc: + conn.reset() + assert exc.value.args[0] in (CR.SERVER_GONE_ERROR, CR.SERVER_LOST) + + +def test_reset_option_file_reconnect(tmp_path): + if not _mysql.get_client_info().startswith("3."): + pytest.skip("Connector/C reconnect option file setting") + config = tmp_path / "reconnect.cnf" + defaults = connection_kwargs({})["read_default_file"] + config.write_text(Path(defaults).read_text() + "\n[MySQLdb-tests]\nreconnect=1\n") + with ( + connection_factory(read_default_file=str(config)) as conn, + connection_factory() as killer, + ): + assert conn.reset() is None + killer.query(f"KILL CONNECTION {conn.thread_id()}") + for _ in range(2): + with pytest.raises(OperationalError) as exc: + conn.reset() + assert exc.value.args[0] in (CR.SERVER_GONE_ERROR, CR.SERVER_LOST) + + +def test_reset_pending_results(): + with connection_factory() as conn: + conn.query("SELECT 1; SELECT 2") + with pytest.raises(ProgrammingError, match="pending results"): + conn.reset() + assert conn.store_result().fetch_row() == ((1,),) + with pytest.raises(ProgrammingError, match="pending results"): + conn.reset() + assert conn.next_result() == 0 + assert conn.store_result().fetch_row() == ((2,),) + assert conn.next_result() == -1 + assert conn.reset() is None + + +def test_reset_streaming_result(): + with connection_factory() as conn: + conn.query("SELECT 1 UNION ALL SELECT 2") + result = conn.use_result() + with pytest.raises(ProgrammingError, match="pending results"): + conn.reset() + assert result.fetch_row(0) == ((1,), (2,)) + del result + assert conn.reset() is None + + +def test_reset_no_arguments(): + with connection_factory() as conn: + with pytest.raises(TypeError): + conn.reset(True) + with pytest.raises(TypeError): + conn.reset(reconnect=True)