From 5ec8c10c31cb67d9ca9ab7036f96bf547dbae66e Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Tue, 26 May 2026 18:52:43 +0300 Subject: [PATCH] db.mysql: support multi-statement queries (fixes #18061, #18063) (#27271) --- vlib/db/mysql/_cdefs.c.v | 9 ++++ vlib/db/mysql/mysql.c.v | 108 +++++++++++++++++++++++++++++++++++++ vlib/db/mysql/mysql_test.v | 63 ++++++++++++++++++++++ 3 files changed, 180 insertions(+) diff --git a/vlib/db/mysql/_cdefs.c.v b/vlib/db/mysql/_cdefs.c.v index 104d7f6f8..ce3eafd98 100644 --- a/vlib/db/mysql/_cdefs.c.v +++ b/vlib/db/mysql/_cdefs.c.v @@ -110,6 +110,15 @@ fn C.mysql_ping(mysql &C.MYSQL) i32 // It is a synchronous function. fn C.mysql_store_result(mysql &C.MYSQL) &C.MYSQL_RES +// C.mysql_more_results returns `true` if more result sets are available from the +// previously executed multi-statement query. +fn C.mysql_more_results(mysql &C.MYSQL) bool + +// C.mysql_next_result advances to the next result set of a multi-statement query. +// Returns `0` on success when another result set is available, `-1` when there +// are no more result sets, and a positive value if an error occurred. +fn C.mysql_next_result(mysql &C.MYSQL) i32 + // C.mysql_fetch_row retrieves the next row of a result set. fn C.mysql_fetch_row(res &C.MYSQL_RES) &charptr diff --git a/vlib/db/mysql/mysql.c.v b/vlib/db/mysql/mysql.c.v index 1f2cec907..6f8a2ab59 100644 --- a/vlib/db/mysql/mysql.c.v +++ b/vlib/db/mysql/mysql.c.v @@ -176,6 +176,12 @@ pub fn connect(config Config) !DB { // query executes the SQL statement pointed to by the string `q`. // It cannot be used for statements that contain binary data; // Use `real_query()` instead. +// +// When the connection was opened with `ConnectionFlag.client_multi_statements` +// and `q` contains more than one statement, only the first result set is +// returned; the server queues the remaining statements and they will only +// finish executing as the client drains them with `next_result()`. Use +// `exec_multi()` to run multi-statement queries to completion. pub fn (db &DB) query(q string) !Result { mut guard := db.acquire_connection_guard()! defer { @@ -210,6 +216,12 @@ pub fn (db &DB) use_result() { // (Binary data may contain the `\0` character, which `query()` // interprets as the end of the statement string). In addition, // `real_query()` is faster than `query()`. +// +// When the connection was opened with `ConnectionFlag.client_multi_statements` +// and `q` contains more than one statement, only the first result set is +// returned; the server queues the remaining statements and they will only +// finish executing as the client drains them with `next_result()`. Use +// `exec_multi()` to run multi-statement queries to completion. pub fn (mut db DB) real_query(q string) !Result { mut guard := db.acquire_connection_guard()! defer { @@ -223,6 +235,102 @@ pub fn (mut db DB) real_query(q string) !Result { return Result{result} } +// more_results reports whether more result sets are available from the most +// recently executed multi-statement query (issued with +// `ConnectionFlag.client_multi_statements`). Pair it with `next_result()` to +// drain pending result sets; otherwise the connection is left in a +// "Commands out of sync" state and cannot run further statements. +pub fn (db &DB) more_results() bool { + mut guard := db.acquire_connection_guard() or { return false } + defer { + guard.release() + } + return C.mysql_more_results(guard.conn) +} + +// next_result advances the connection to the next result set of a +// multi-statement query. It returns `true` if another result set is now +// available (and can be read with `store_result()`/`Result.rows()`), `false` +// if there are no more result sets, and an error if the server reported one. +// +// Any previous result set must be freed (via `Result.free()` or by consuming +// it with `Result.rows()` and then `Result.free()`) before calling +// `next_result()`. The high-level `exec_multi()` handles this automatically. +pub fn (mut db DB) next_result() !bool { + mut guard := db.acquire_connection_guard()! + defer { + guard.release() + } + code := C.mysql_next_result(guard.conn) + if code == 0 { + return true + } + if code == -1 { + return false + } + throw_mysql_error_for_conn(guard.conn)! + return false +} + +// store_result reads the result of the current statement into a `Result`, +// which the caller is responsible for freeing (via `Result.free()`). This is +// useful in combination with `next_result()` for iterating over result sets +// of a multi-statement query started by `query()` or `real_query()`. +// If the current statement did not produce a result set (e.g. an `INSERT`), +// the returned `Result` has a `nil` inner pointer. +// Returns an error if the server reported one. +pub fn (db &DB) store_result() !Result { + mut guard := db.acquire_connection_guard()! + defer { + guard.release() + } + c_result := C.mysql_store_result(guard.conn) + if isnil(c_result) && get_errno(guard.conn) != 0 { + throw_mysql_error_for_conn(guard.conn)! + } + return Result{c_result} +} + +// exec_multi executes a multi-statement `query` against the server and +// returns one entry per executed statement, in execution order. Statements +// that produce a result set (e.g. `SELECT`) contribute their rows; statements +// that do not (e.g. `INSERT`, `UPDATE`) contribute an empty `[]Row`. +// +// Use this with connections opened using `ConnectionFlag.client_multi_statements` +// so that the connection is fully drained and remains usable for subsequent +// queries. All intermediate result sets are freed automatically. +pub fn (mut db DB) exec_multi(query string) ![][]Row { + mut guard := db.acquire_connection_guard()! + defer { + guard.release() + } + if C.mysql_real_query(guard.conn, query.str, query.len) != 0 { + throw_mysql_error_for_conn(guard.conn)! + } + mut results := [][]Row{} + for { + c_result := C.mysql_store_result(guard.conn) + if !isnil(c_result) { + rows := Result{c_result}.rows() + C.mysql_free_result(c_result) + results << rows + } else { + if get_errno(guard.conn) != 0 { + throw_mysql_error_for_conn(guard.conn)! + } + results << []Row{} + } + code := C.mysql_next_result(guard.conn) + if code == -1 { + break + } + if code > 0 { + throw_mysql_error_for_conn(guard.conn)! + } + } + return results +} + // select_db causes the database specified by `db` to become // the default (current) database on the connection specified by mysql. pub fn (mut db DB) select_db(dbname string) !bool { diff --git a/vlib/db/mysql/mysql_test.v b/vlib/db/mysql/mysql_test.v index 7071c8391..3e9b28e39 100644 --- a/vlib/db/mysql/mysql_test.v +++ b/vlib/db/mysql/mysql_test.v @@ -145,6 +145,69 @@ fn test_mysql() { ] } +fn test_mysql_multi_statements() { + $if !network ? { + eprintln('> Skipping test ${@FN}, since `-d network` is not passed.') + eprintln('> This test requires a working mysql server running on localhost.') + return + } + config := mysql.Config{ + host: '127.0.0.1' + port: 3306 + username: 'root' + password: '12345678' + dbname: 'mysql' + flag: mysql.ConnectionFlag.client_multi_statements + } + + mut db := mysql.connect(config)! + defer { + db.close() or {} + } + + // exec_multi drains every result set so the connection stays usable. + results := db.exec_multi('SELECT 1 AS a; SELECT 2 AS b; SELECT 3 AS c')! + assert results.len == 3 + assert results[0][0].val(0) == '1' + assert results[1][0].val(0) == '2' + assert results[2][0].val(0) == '3' + + // The connection must remain reusable afterwards: regression test for #18061. + follow_up := db.query('SELECT 42')! + rows := follow_up.rows() + assert rows[0].val(0) == '42' + + // Low-level drain via more_results/next_result/store_result also works. + first := db.query('SELECT 10; SELECT 20')! + assert first.rows()[0].val(0) == '10' + unsafe { first.free() } + assert db.more_results() == true + assert db.next_result()! == true + second := db.store_result()! + assert second.rows()[0].val(0) == '20' + unsafe { second.free() } + assert db.next_result()! == false + // After draining, new queries succeed (no "Commands out of sync" error). + final := db.query('SELECT 99')! + assert final.rows()[0].val(0) == '99' + + // Regression test for #18063: every statement in a multi-statement query + // must complete on the server before exec_multi() returns, so that the + // next query can immediately rely on its side effects (e.g. tables). + _ := db.exec_multi('DROP TABLE IF EXISTS multi_a; + DROP TABLE IF EXISTS multi_b; + CREATE TABLE multi_a (id INT PRIMARY KEY); + CREATE TABLE multi_b (id INT PRIMARY KEY); + INSERT INTO multi_a (id) VALUES (1), (2); + INSERT INTO multi_b (id) VALUES (10), (20), (30);')! + count_a := db.query('SELECT COUNT(*) FROM multi_a')!.rows() + count_b := db.query('SELECT COUNT(*) FROM multi_b')!.rows() + assert count_a[0].val(0) == '2' + assert count_b[0].val(0) == '3' + db.exec_none('DROP TABLE IF EXISTS multi_a') + db.exec_none('DROP TABLE IF EXISTS multi_b') +} + fn mysql_query_count_from_shared_connection(db mysql.DB) !int { result := db.query('SELECT COUNT(*) as table_count FROM information_schema.tables')! rows := result.maps() -- 2.39.5