diff --git a/packets.go b/packets.go index d0b21b06c..ff739c127 100644 --- a/packets.go +++ b/packets.go @@ -866,6 +866,14 @@ func (rows *textRows) readRow(dest []driver.Value) error { continue } + if rows.raw { + // Wire-format pass-through: no parse/format round trip and no + // lossy conversions (zero dates, float formatting) for callers + // that forward values verbatim. + dest[i] = buf + continue + } + switch rows.rs.columns[i].fieldType { case fieldTypeTimestamp, fieldTypeDateTime, diff --git a/rows.go b/rows.go index 190e75f9b..1fffd480e 100644 --- a/rows.go +++ b/rows.go @@ -25,6 +25,11 @@ type mysqlRows struct { mc *mysqlConn rs resultSet finish func() + // raw delivers every non-NULL cell as its MySQL wire text ([]byte, + // aliasing the connection buffer) instead of parsing numeric and + // temporal columns into Go types. Set only on rows produced by + // QueryResultContext, which never pass through database/sql conversion. + raw bool } type binaryRows struct { @@ -94,6 +99,12 @@ func (rows *mysqlRows) ColumnTypePrecisionScale(i int) (int64, int64, bool) { } func (rows *mysqlRows) ColumnTypeScanType(i int) reflect.Type { + if rows.raw { + // Next delivers wire text for every non-NULL cell regardless of the + // column's MySQL type, so report what it actually yields. The other + // ColumnType methods describe the column itself and stay accurate. + return scanTypeBytes + } return rows.rs.columns[i].scanType() } diff --git a/unified.go b/unified.go new file mode 100644 index 000000000..63e31a80f --- /dev/null +++ b/unified.go @@ -0,0 +1,141 @@ +// 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" +) + +// QueryResultContext executes query and returns the server's response in the +// form the MySQL protocol describes it: exactly one of rows or result is +// non-nil on success. A statement that produces a resultset (even an empty +// one) yields rows; a statement answered with an OK packet (INSERT, UPDATE, +// DDL, SET, ...) yields result, carrying the affected-row count and last +// insert id. +// +// database/sql callers must choose between QueryContext and ExecContext +// before executing. A caller that receives arbitrary SQL (a proxy, a REPL) +// therefore has to classify statements up front, and a misclassification +// either discards a resultset (Exec) or loses the OK metadata (Query). This +// method lets such callers branch on what the server actually sent instead. +// It is intended to be reached through (*sql.Conn).Raw and a structural +// interface assertion: +// +// err := conn.Raw(func(dc any) error { +// uq := dc.(interface { +// QueryResultContext(context.Context, string, []driver.NamedValue) (driver.Rows, driver.Result, error) +// }) +// rows, result, err := uq.QueryResultContext(ctx, query, args) +// ... +// }) +// +// Named parameters are rejected, as they are by QueryContext and ExecContext. +// Because Raw also bypasses database/sql's argument conversion, args are +// normalized here with CheckNamedValue, exactly as database/sql would do +// before QueryContext. As with QueryContext, non-empty args require +// InterpolateParams; otherwise driver.ErrSkip is returned before anything is +// written. driver.ErrBadConn is returned only when no command reached the +// server, so the caller may safely retry on it. When rows is non-nil the +// connection is busy until rows.Close. +// +// Unlike QueryContext, rows delivers every non-NULL cell as its MySQL wire +// text: a []byte aliasing the connection's read buffer, valid only until the +// next Next or Close call. Nothing is parsed into Go types (parseTime does +// not apply), so values forward verbatim — zero dates, float representations, +// and fractional-second padding survive untouched. +func (mc *mysqlConn) QueryResultContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Rows, driver.Result, error) { + if mc.closed.Load() { + return nil, nil, driver.ErrBadConn + } + + // Reject named parameters exactly as QueryContext and ExecContext do. + dargs, err := namedValueToValue(args) + if err != nil { + return nil, nil, err + } + + // Raw bypasses database/sql's argument conversion, which on the normal + // path runs CheckNamedValue before the driver sees the args. Apply it + // here so interpolateParams gets the same values either way. + for i := range dargs { + nv := driver.NamedValue{Ordinal: i + 1, Value: dargs[i]} + if err := mc.CheckNamedValue(&nv); err != nil { + return nil, nil, err + } + dargs[i] = nv.Value + } + + if err := mc.watchCancel(ctx); err != nil { + return nil, nil, err + } + + rows, result, err := mc.queryResult(query, dargs) + if err != nil || rows == nil { + // Error, or a complete OK response: the connection is idle again. + mc.finish() + return nil, result, err + } + // Resultset: the context watcher stays armed until the caller finishes + // reading, mirroring QueryContext. + rows.finish = mc.finish + return rows, nil, nil +} + +// queryResult is the transport half of QueryResultContext. It mirrors +// mysqlConn.query, except that an OK response is surfaced as a driver.Result +// (the way Exec reports it) instead of being hidden inside an empty, done +// resultset. +func (mc *mysqlConn) queryResult(query string, args []driver.Value) (*textRows, driver.Result, error) { + handleOk := mc.clearResult() + + if len(args) != 0 { + if !mc.cfg.InterpolateParams { + return nil, nil, driver.ErrSkip + } + // try client-side prepare to reduce roundtrip + prepared, err := mc.interpolateParams(query, args) + if err != nil { + return nil, nil, err + } + query = prepared + } + + // Send command + if err := mc.writeCommandPacketStr(comQuery, query); err != nil { + return nil, nil, mc.markBadConn(err) + } + + // Read Result + resLen, _, err := handleOk.readResultSetHeaderPacket() + if err != nil { + return nil, nil, err + } + + if resLen == 0 { + // OK packet: no resultset follows. Drain any trailing results of a + // multi-statement exactly like exec, then surface the accumulated + // result the same way Exec does. + if err := handleOk.discardResults(); err != nil { + return nil, nil, err + } + copied := mc.result + return nil, &copied, nil + } + + // Columns + rows := new(textRows) + rows.mc = mc + rows.raw = true + rows.rs.columns, err = mc.readColumns(resLen, nil) + if err != nil { + return nil, nil, err + } + return rows, nil, nil +} diff --git a/unified_test.go b/unified_test.go new file mode 100644 index 000000000..d0a08e754 --- /dev/null +++ b/unified_test.go @@ -0,0 +1,196 @@ +// 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 ( + "bytes" + "context" + "database/sql/driver" + "errors" + "io" + "reflect" + "strings" + "testing" +) + +// unifiedExec runs query through QueryResultContext on a raw connection and +// returns either the fully-read resultset (columns + rows, cells copied) or +// the OK-response counters. +type unifiedResponse struct { + columns []string + scanTypes []reflect.Type + rows [][][]byte // nil cell = NULL + isResultset bool + rowsAffected int64 + lastInsertID int64 +} + +func unifiedExec(ctx context.Context, dbt *DBTest, query string, args []driver.NamedValue) (*unifiedResponse, error) { + conn, err := dbt.db.Conn(ctx) + if err != nil { + dbt.Fatalf("getting conn: %s", err) + } + defer conn.Close() + + resp := &unifiedResponse{} + rawErr := conn.Raw(func(dc any) error { + mc, ok := dc.(*mysqlConn) + if !ok { + dbt.Fatalf("driver conn is %T, not *mysqlConn", dc) + } + rows, result, err := mc.QueryResultContext(ctx, query, args) + if err != nil { + return err + } + if rows == nil { + resp.rowsAffected, _ = result.RowsAffected() + resp.lastInsertID, _ = result.LastInsertId() + return nil + } + defer rows.Close() + resp.isResultset = true + resp.columns = rows.Columns() + if st, ok := rows.(driver.RowsColumnTypeScanType); ok { + resp.scanTypes = make([]reflect.Type, len(resp.columns)) + for i := range resp.scanTypes { + resp.scanTypes[i] = st.ColumnTypeScanType(i) + } + } + dest := make([]driver.Value, len(resp.columns)) + for { + if err := rows.Next(dest); err != nil { + if err == io.EOF { + return nil + } + return err + } + row := make([][]byte, len(dest)) + for i, v := range dest { + if v == nil { + continue + } + b, ok := v.([]byte) + if !ok { + dbt.Fatalf("unified cell %d is %T, want []byte (wire pass-through)", i, v) + } + row[i] = bytes.Clone(b) + } + resp.rows = append(resp.rows, row) + } + }) + if rawErr != nil { + return nil, rawErr + } + return resp, nil +} + +func TestQueryResultContext(t *testing.T) { + runTestsParallel(t, dsn, func(dbt *DBTest, tbl string) { + ctx := context.Background() + dbt.mustExec("CREATE TABLE " + tbl + " (id INT PRIMARY KEY AUTO_INCREMENT, dt DATETIME, note VARCHAR(16))") + + // OK response: rows must be nil, counters populated. + resp, err := unifiedExec(ctx, dbt, "INSERT INTO "+tbl+" (dt, note) VALUES ('2026-08-01 12:00:00', 'a'), (NULL, 'b')", nil) + if err != nil { + dbt.Fatalf("insert: %s", err) + } + if resp.isResultset { + dbt.Fatal("INSERT returned a resultset") + } + if resp.rowsAffected != 2 || resp.lastInsertID != 1 { + dbt.Fatalf("INSERT counters: affected=%d insertID=%d", resp.rowsAffected, resp.lastInsertID) + } + + // Resultset: cells arrive as raw wire text, NULL as nil. + resp, err = unifiedExec(ctx, dbt, "SELECT id, dt, note FROM "+tbl+" ORDER BY id", nil) + if err != nil { + dbt.Fatalf("select: %s", err) + } + if !resp.isResultset || len(resp.columns) != 3 || len(resp.rows) != 2 { + dbt.Fatalf("SELECT shape: resultset=%v cols=%v rows=%d", resp.isResultset, resp.columns, len(resp.rows)) + } + if got := string(resp.rows[0][0]); got != "1" { + dbt.Errorf("id wire text: %q", got) + } + if got := string(resp.rows[0][1]); got != "2026-08-01 12:00:00" { + dbt.Errorf("datetime wire text: %q", got) + } + if resp.rows[1][1] != nil { + dbt.Errorf("NULL cell: %q", resp.rows[1][1]) + } + + // ColumnTypeScanType must describe what Next actually delivers on + // this path — []byte for every column, whatever its MySQL type — + // rather than the Go type the parsing path would have produced. + for i, st := range resp.scanTypes { + if st != scanTypeBytes { + dbt.Errorf("scan type for column %q: %v, want []uint8", resp.columns[i], st) + } + } + + // Empty resultset is still a resultset, not an OK response. + resp, err = unifiedExec(ctx, dbt, "SELECT id FROM "+tbl+" WHERE 1 = 0", nil) + if err != nil { + dbt.Fatalf("empty select: %s", err) + } + if !resp.isResultset || len(resp.rows) != 0 { + dbt.Fatalf("empty SELECT shape: resultset=%v rows=%d", resp.isResultset, len(resp.rows)) + } + + // A statement callers routinely misclassify: CHECK TABLE answers with + // a resultset even though it reads like an admin/modify statement. + resp, err = unifiedExec(ctx, dbt, "CHECK TABLE "+tbl, nil) + if err != nil { + dbt.Fatalf("check table: %s", err) + } + if !resp.isResultset || len(resp.rows) != 1 { + dbt.Fatalf("CHECK TABLE shape: resultset=%v rows=%d", resp.isResultset, len(resp.rows)) + } + if got := string(resp.rows[0][3]); got != "OK" { + dbt.Errorf("CHECK TABLE msg: %q", got) + } + + // Args require InterpolateParams (pre-write driver.ErrSkip without + // it), exactly like QueryContext. The harness runs this test under + // both DSN variants, so accept either outcome — but each must be + // exact. + args := []driver.NamedValue{{Ordinal: 1, Value: int64(1)}} + resp, err = unifiedExec(ctx, dbt, "SELECT note FROM "+tbl+" WHERE id = ?", args) + if err != nil { + if !errors.Is(err, driver.ErrSkip) { + dbt.Fatalf("args select: %s", err) + } + } else if len(resp.rows) != 1 || string(resp.rows[0][0]) != "a" { + dbt.Fatalf("args SELECT rows: %v", resp.rows) + } + + // Named parameters are unsupported, exactly as on QueryContext. This + // is checked before the InterpolateParams gate, so the outcome is the + // same under both DSN variants and must not be a silent positional + // bind. + named := []driver.NamedValue{{Name: "id", Ordinal: 1, Value: int64(1)}} + if _, err = unifiedExec(ctx, dbt, "SELECT note FROM "+tbl+" WHERE id = ?", named); err == nil { + dbt.Fatal("named parameter accepted") + } else if !strings.Contains(err.Error(), "Named Parameters") { + dbt.Fatalf("named parameter error: %s", err) + } + + // Errors surface normally and leave the connection reusable. + if _, err = unifiedExec(ctx, dbt, "SELECT syntax error from", nil); err == nil { + dbt.Fatal("syntax error did not surface") + } + var myErr *MySQLError + if _, err = unifiedExec(ctx, dbt, "SELECT * FROM does_not_exist_"+tbl, nil); !errors.As(err, &myErr) { + dbt.Fatalf("missing table error: %v", err) + } + if resp, err = unifiedExec(ctx, dbt, "SELECT COUNT(*) FROM "+tbl, nil); err != nil || string(resp.rows[0][0]) != "2" { + dbt.Fatalf("conn not reusable after errors: %v %v", resp, err) + } + }) +}