diff --git a/connection.go b/connection.go index 5a25c878a..3f953eb6f 100644 --- a/connection.go +++ b/connection.go @@ -36,6 +36,7 @@ type mysqlConn struct { capabilities capabilityFlag extCapabilities extendedCapabilityFlag status statusFlag + warnings uint16 // managed by resetSequence() and the OK/EOF readers; see Warnings(). sequence uint8 compressSequence uint8 parseTime bool @@ -88,6 +89,12 @@ func (mc *mysqlConn) writeWithTimeout(b []byte) (int, error) { func (mc *mysqlConn) resetSequence() { mc.sequence = 0 mc.compressSequence = 0 + // Sending a command is also what ends the previous command's diagnostics, + // so this is where the warning count resets. Deliberately not clearResult(): + // that also runs from (*mysqlRows).Close, which happens after the packet + // carrying the count has been read and would throw the count away before + // the caller could ask for it. + mc.warnings = 0 } // syncSequence must be called when finished writing some packet and before start reading. diff --git a/packets.go b/packets.go index ff739c127..4851246ce 100644 --- a/packets.go +++ b/packets.go @@ -632,6 +632,37 @@ func readStatus(b []byte) statusFlag { return statusFlag(b[0]) | statusFlag(b[1])<<8 } +// readResultsetTerminator records the status flags and warning count carried by +// the packet that ends a resultset: a classic EOF packet, or an OK packet sent +// with an 0xFE header once CLIENT_DEPRECATE_EOF is negotiated. The two layouts +// order those two fields differently, which is why reading them lives in one +// place. data is the whole packet, starting at the 0xFE header. +func (mc *mysqlConn) readResultsetTerminator(data []byte) { + if mc.capabilities&clientDeprecateEOF == 0 { + // Deprecated EOF packet: header, warning count, status flags. + // https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_basic_eof_packet.html + mc.warnings = readWarnings(data[1:]) + mc.status = readStatus(data[3:]) + return + } + // OK packet with an 0xFE header: status flags precede the warning count. + _, _, n := readLengthEncodedInteger(data[1:]) // affected_rows + _, _, m := readLengthEncodedInteger(data[1+n:]) // last_insert_id + mc.status = readStatus(data[1+n+m:]) + mc.warnings = readWarnings(data[1+n+m+2:]) +} + +// readWarnings reads the two-byte warning count that OK and EOF packets carry +// under CLIENT_PROTOCOL_41. The driver always negotiates that capability, so a +// buffer too short to hold the field means a malformed packet rather than an +// older server; report no warnings rather than panicking on a slice bound. +func readWarnings(b []byte) uint16 { + if len(b) < 2 { + return 0 + } + return uint16(b[0]) | uint16(b[1])<<8 +} + // Returns an instance of okHandler for codepaths where mysqlConn.result doesn't // need to be cleared first (e.g. during authentication, or while additional // resultsets are being fetched.) @@ -656,8 +687,7 @@ func (mc *okHandler) conn() *mysqlConn { return (*mysqlConn)(mc) } -// clearResult clears the connection's stored affectedRows and insertIds -// fields. +// clearResult clears the connection's stored affectedRows and insertIds. // // It returns a handler that can process OK responses. func (mc *mysqlConn) clearResult() *okHandler { @@ -690,11 +720,13 @@ func (mc *okHandler) handleOkPacket(data []byte) error { // server_status [2 bytes] mc.status = readStatus(data[1+n+m : 1+n+m+2]) - if mc.status&statusMoreResultsExists != 0 { - return nil - } // warning count [2 bytes] + // + // Recorded unconditionally, including when statusMoreResultsExists is set: + // every statement of a multi-statement gets an OK packet of its own, and + // each should leave its own count behind rather than only the last. + mc.warnings = readWarnings(data[1+n+m+2:]) return nil } @@ -823,16 +855,7 @@ func (rows *textRows) readRow(dest []driver.Value) error { // In such case, 0xFE can mean string larger than 0xffffff. // https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_basic_dt_integers.html#sect_protocol_basic_dt_int_le if data[0] == iEOF && len(data) <= 0xffffff { - if mc.capabilities&clientDeprecateEOF == 0 { - // Deprecated EOF packet - // https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_basic_eof_packet.html - mc.status = readStatus(data[3:]) - } else { - // Ok Packet with an 0xFE header - _, _, n := readLengthEncodedInteger(data[1:]) // affected_rows - _, _, m := readLengthEncodedInteger(data[1+n:]) // last_insert_id - mc.status = readStatus(data[1+n+m:]) - } + mc.readResultsetTerminator(data) rows.rs.done = true if !rows.HasNextResultSet() { rows.mc = nil @@ -955,15 +978,7 @@ func (mc *mysqlConn) skipRows() error { // text row packets may starts with LengthEncodedString. // In such case, 0xFE can mean string larger than 0xffffff. if len(data) <= 0xffffff { - if mc.capabilities&clientDeprecateEOF == 0 { - // EOF packet - mc.status = readStatus(data[3:]) - } else { - // OK packet with an 0xFE header - _, _, n := readLengthEncodedInteger(data[1:]) // affected_rows - _, _, m := readLengthEncodedInteger(data[1+n:]) // last_insert_id - mc.status = readStatus(data[1+n+m:]) - } + mc.readResultsetTerminator(data) return nil } } @@ -1281,15 +1296,7 @@ func (rows *binaryRows) readRow(dest []driver.Value) error { if data[0] != iOK { // EOF/OK Packet if data[0] == iEOF { - if rows.mc.capabilities&clientDeprecateEOF == 0 { - // EOF packet - rows.mc.status = readStatus(data[3:]) - } else { - // OK Packet with an 0xFE header - _, _, n := readLengthEncodedInteger(data[1:]) - _, _, m := readLengthEncodedInteger(data[1+n:]) - rows.mc.status = readStatus(data[1+n+m:]) - } + rows.mc.readResultsetTerminator(data) rows.rs.done = true if !rows.HasNextResultSet() { rows.mc = nil diff --git a/warnings.go b/warnings.go new file mode 100644 index 000000000..e48c947bf --- /dev/null +++ b/warnings.go @@ -0,0 +1,46 @@ +// Go MySQL Driver - A MySQL-Driver for Go's database/sql package +// +// Copyright 2026 The Go-MySQL-Driver Authors. All rights reserved. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. + +package mysql + +// Warnings reports the warning count the server sent in the packet that +// terminated the last statement executed on this connection — the OK packet +// for a statement without a resultset, the EOF (or 0xFE-headered OK) packet +// that ends the rows otherwise. It is the same number MySQL's own client +// prints as "N warnings" and exposes as @@warning_count. +// +// The count is what makes reading warnings affordable. MySQL keeps the +// diagnostics themselves in per-connection state that only SHOW WARNINGS can +// read, so a caller that wants them has to spend a round trip; the count says +// whether there is anything to spend it on. Clients that surface warnings — +// Connector/J among them — use it exactly that way. +// +// It is valid from the moment the statement's response is complete until the +// next statement starts on this connection, which resets it to zero. For a +// resultset that means after the rows have been fully read (or Close called): +// the terminating packet carrying the count has not arrived before then. +// +// An error packet carries no count of its own, so a statement that fails +// reports zero — the value the statement's own start left behind. Inside a +// multi-statement, where each statement gets its own OK packet, a failure +// instead leaves the last successful statement's count in place. +// +// Reach it through (*sql.Conn).Raw and an interface assertion: +// +// err := conn.Raw(func(dc any) error { +// if wc, ok := dc.(interface{ Warnings() uint16 }); ok && wc.Warnings() > 0 { +// // SHOW WARNINGS on this same connection +// } +// return nil +// }) +// +// Note that database/sql may hand the connection to another caller as soon as +// it is released, so the count must be read before that happens. +func (mc *mysqlConn) Warnings() uint16 { + return mc.warnings +} diff --git a/warnings_test.go b/warnings_test.go new file mode 100644 index 000000000..4eb465a10 --- /dev/null +++ b/warnings_test.go @@ -0,0 +1,235 @@ +// Go MySQL Driver - A MySQL-Driver for Go's database/sql package +// +// Copyright 2026 The Go-MySQL-Driver Authors. All rights reserved. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. + +package mysql + +import ( + "context" + "database/sql/driver" + "errors" + "io" + "testing" +) + +func TestReadWarnings(t *testing.T) { + if got := readWarnings([]byte{0x07, 0x00}); got != 7 { + t.Errorf("little-endian decode: got %d, want 7", got) + } + if got := readWarnings([]byte{0x00, 0x01}); got != 256 { + t.Errorf("high byte: got %d, want 256", got) + } + // A packet too short to hold the field must report zero rather than + // panicking on the slice bound. + if got := readWarnings([]byte{0x07}); got != 0 { + t.Errorf("short buffer: got %d, want 0", got) + } + if got := readWarnings(nil); got != 0 { + t.Errorf("empty buffer: got %d, want 0", got) + } +} + +func TestHandleOkPacketWarnings(t *testing.T) { + // OK packet: header, affected_rows, last_insert_id, status, warnings. + data := []byte{iOK, 0x03, 0x07, 0x02, 0x00, 0x05, 0x00} + + mc := new(mysqlConn) + if err := mc.clearResult().handleOkPacket(data); err != nil { + t.Fatalf("handleOkPacket: %s", err) + } + if got := mc.Warnings(); got != 5 { + t.Errorf("Warnings: got %d, want 5", got) + } + if got := mc.status; got != 0x0002 { + t.Errorf("status: got %#04x, want 0x0002", got) + } + + // Sending the next command clears the previous statement's count, so a + // statement that warns cannot make a later quiet statement look noisy. + mc.resetSequence() + if got := mc.Warnings(); got != 0 { + t.Errorf("Warnings after resetSequence: got %d, want 0", got) + } +} + +// TestClearResultKeepsWarnings pins where the reset does *not* happen. +// (*mysqlRows).Close calls clearResult after the packet carrying the count has +// already been read, so clearing there would leave every resultset reporting +// zero to a caller who read the count the only way it can be read: after Close. +func TestClearResultKeepsWarnings(t *testing.T) { + mc := new(mysqlConn) + mc.readResultsetTerminator([]byte{iEOF, 0x04, 0x00, 0x02, 0x00}) + mc.clearResult() + if got := mc.Warnings(); got != 4 { + t.Errorf("Warnings after clearResult: got %d, want 4", got) + } +} + +func TestHandleOkPacketWarningsWithMoreResults(t *testing.T) { + // statusMoreResultsExists set: the count still belongs to the statement + // that just finished, so it must be recorded before the early return. + data := []byte{iOK, 0x00, 0x00, byte(statusMoreResultsExists), 0x00, 0x02, 0x00} + + mc := new(mysqlConn) + if err := mc.clearResult().handleOkPacket(data); err != nil { + t.Fatalf("handleOkPacket: %s", err) + } + if got := mc.Warnings(); got != 2 { + t.Errorf("Warnings: got %d, want 2", got) + } +} + +func TestReadResultsetTerminatorWarnings(t *testing.T) { + // Deprecated EOF packet: header, warnings, status. Note the field order is + // the reverse of the OK packet's — the point of the shared reader. + mc := new(mysqlConn) + mc.readResultsetTerminator([]byte{iEOF, 0x04, 0x00, 0x02, 0x00}) + if got := mc.Warnings(); got != 4 { + t.Errorf("EOF packet warnings: got %d, want 4", got) + } + if got := mc.status; got != 0x0002 { + t.Errorf("EOF packet status: got %#04x, want 0x0002", got) + } + + // OK packet with an 0xFE header, sent in place of EOF once + // CLIENT_DEPRECATE_EOF is negotiated: status, then warnings. + mc = new(mysqlConn) + mc.capabilities |= clientDeprecateEOF + mc.readResultsetTerminator([]byte{iEOF, 0x00, 0x00, 0x02, 0x00, 0x04, 0x00}) + if got := mc.Warnings(); got != 4 { + t.Errorf("deprecate-EOF warnings: got %d, want 4", got) + } + if got := mc.status; got != 0x0002 { + t.Errorf("deprecate-EOF status: got %#04x, want 0x0002", got) + } +} + +// execWarnings runs queries in order on one connection and reports the warning +// count left behind by the last of them, reading it the way an external caller +// must: through Raw, after the response is complete. The error from the last +// query is returned rather than fatal, so a failing statement can be probed. +// +// finish is how each resultset is disposed of before the count is read. That is +// a real variable in callers — draining to EOF and closing an undrained +// resultset take different paths through the driver — so it is a parameter. +func execWarnings( + ctx context.Context, dbt *DBTest, finish func(driver.Rows) error, queries ...string, +) (uint16, error) { + dbt.Helper() + conn, err := dbt.db.Conn(ctx) + if err != nil { + dbt.Fatalf("getting conn: %s", err) + } + defer conn.Close() + + var ( + warnings uint16 + queryErr error + ) + if err := conn.Raw(func(dc any) error { + mc := dc.(*mysqlConn) + for _, query := range queries { + rows, _, err := mc.QueryResultContext(ctx, query, nil) + if err != nil { + queryErr = err + break + } + if rows != nil { + // The terminating packet that carries the count has not been + // read until the resultset is finished. + if err := finish(rows); err != nil { + return err + } + } + } + warnings = mc.Warnings() + return nil + }); err != nil { + dbt.Fatalf("%v: %s", queries, err) + } + return warnings, queryErr +} + +// connWarnings is execWarnings for a single drained query expected to succeed. +func connWarnings(ctx context.Context, dbt *DBTest, query string) uint16 { + dbt.Helper() + warnings, err := execWarnings(ctx, dbt, drainRows, query) + if err != nil { + dbt.Fatalf("%s: %s", query, err) + } + return warnings +} + +// connWarningsUndrained is connWarnings for a caller that closes the resultset +// without reading it, which is what a proxy forwarding rows elsewhere does. +func connWarningsUndrained(ctx context.Context, dbt *DBTest, query string) uint16 { + dbt.Helper() + warnings, err := execWarnings(ctx, dbt, driver.Rows.Close, query) + if err != nil { + dbt.Fatalf("%s: %s", query, err) + } + return warnings +} + +func drainRows(rows driver.Rows) error { + defer rows.Close() + dest := make([]driver.Value, len(rows.Columns())) + for { + err := rows.Next(dest) + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + } +} + +func TestWarnings(t *testing.T) { + runTestsParallel(t, dsn, func(dbt *DBTest, tbl string) { + ctx := context.Background() + dbt.mustExec("CREATE TABLE " + tbl + " (id INT PRIMARY KEY, note VARCHAR(4))") + + if got := connWarnings(ctx, dbt, "INSERT INTO "+tbl+" VALUES (1, 'ok')"); got != 0 { + dbt.Errorf("clean INSERT: got %d warnings, want 0", got) + } + + // DROP TABLE IF EXISTS on a missing table is note 1051, reported in + // the OK packet's warning count. + if got := connWarnings(ctx, dbt, "DROP TABLE IF EXISTS "+tbl+"_absent"); got != 1 { + dbt.Errorf("DROP IF EXISTS on a missing table: got %d warnings, want 1", got) + } + + // A resultset carries its count in the terminating packet, not in a + // leading OK packet, so this exercises the other reader. + if got := connWarnings(ctx, dbt, "SELECT CAST('abc' AS SIGNED)"); got != 1 { + dbt.Errorf("truncating CAST: got %d warnings, want 1", got) + } + if got := connWarnings(ctx, dbt, "SELECT * FROM "+tbl); got != 0 { + dbt.Errorf("clean SELECT: got %d warnings, want 0", got) + } + + // Closing a resultset without draining it goes through skipRows and the + // stored-result housekeeping that follows it, all of which runs after + // the terminating packet has been read. The count has to survive that. + if got := connWarningsUndrained(ctx, dbt, "SELECT CAST('abc' AS SIGNED)"); got != 1 { + dbt.Errorf("truncating CAST, undrained: got %d warnings, want 1", got) + } + + // A failed statement reports zero even when the statement before it + // warned: an error packet carries no count of its own, and the failing + // statement's own start already cleared the previous one's. + got, err := execWarnings(ctx, dbt, drainRows, + "SELECT CAST('abc' AS SIGNED)", "SELECT * FROM "+tbl+"_absent") + if err == nil { + dbt.Fatal("expected the second statement to fail") + } + if got != 0 { + dbt.Errorf("after a failed statement: got %d warnings, want 0", got) + } + }) +}