go-sql-driver / go-sql-driver/mysql

proposal: execute arbitrary SQL in one round trip, returning rows or the OK-packet result (QueryResultContext)

Open
#1,793 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Go
Stars
15.3k
Forks
2.3k
Avg merge
2h 23m
Merged PRs (30d)
10

Description

I maintain a MySQL proxy written in Go. Like any tool that executes SQL it didn't write, it must return whatever MySQL sends back: a result set, or the OK packet's affected-rows/last-insert-id.

`database/sql` makes me commit to `Query` or `Exec` *before* the server has said which kind of response the statement produces. For arbitrary SQL that's a guess, and both wrong guesses lose data silently:

- `QueryContext` on a write: the OK packet's `affectedRows`/`insertId` are stored on the connection's unexported fields — I get zero-column rows and the counts are unreachable.
- `ExecContext` on a statement that returns rows: the driver reads and **discards** the result set (`readUntilEOF`) — unrecoverable, the statement already executed.

So today's options look like this (`CALL p()` returns rows or not depending on the procedure body, so no classifier is ever right):

```go
// Option 1: guess — and maintain a SQL parser in order to use a SQL driver.
if looksLikeItReturnsRows(query) {
rows, err = db.QueryContext(ctx, query)
} else {
res, err = db.ExecContext(ctx, query)
}
```

```go
// Option 2: Query everything, then ask the server what happened.
// An extra round trip per statement, and ROW_COUNT()/LAST_INSERT_ID()
// have sticky semantics that make this subtly wrong in edge cases.
rows, err := conn.QueryContext(ctx, query)
// ... drain the zero-column rows ...
err = conn.QueryRowContext(ctx,
"SELECT ROW_COUNT(), LAST_INSERT_ID()").Scan(&affected, &insertID)
```

The protocol doesn't have this problem — a `COM_QUERY` response is self-describing (result set *or* OK packet), and other ecosystems expose that directly:
libmysqlclient (`mysql_field_count() == 0`), JDBC (`Statement.execute()`), pgx (`CommandTag`). Only in Go must the caller guess ahead of the wire.

### What I'd like to write instead

One call that returns what actually came back, reached the same way `mysql.Result` (#1261 → #1309) already is — a type assertion via `sql.Conn.Raw()`:

```go
conn, _ := db.Conn(ctx)
err := conn.Raw(func(dc any) error {
rows, result, err := dc.(mysql.QueryerResult).QueryResultContext(ctx, query, nil)
if err != nil {
return err
}
if rows != nil {
defer rows.Close()
return forwardRows(rows) // the statement returned a result set
}
return forwardOK(result) // OK packet: RowsAffected/LastInsertId available
})
```

No classifier, no second round trip, nothing discarded.

### Proposal

```go
// QueryerResult executes any statement in one round trip. On success exactly
// one of rows/result is non-nil: rows for a result set response, result for
// an OK-packet response (including the mysql.Result extension methods).
type QueryerResult interface {
QueryResultContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Rows, driver.Result, error)
}
```

Optionally (a separate second commit in our patch): rows from this method deliver cells as raw MySQL wire text (`[]byte`, the usual aliasing contract), skipping `parseTime`/numeric conversion — pass-through consumers avoid parse→format round trips and lossy conversions (a `0000-00-00` date can't round-trip through `time.Time`).

### Why not extend `Query`/`Exec` instead?

The helpers are split between the two return types by design: `Result` can never carry rows, and `Rows` gives no path to the OK-packet counts (`database/sql`'s wrapper doesn't forward driver-specific interfaces on rows).

- **`Exec` everything:** the rows are drained to keep the connection command-ready - unrecoverable by the time `Exec` returns. Returning them means buffering unbounded result sets in `Result`, or a new interface anyway.
- **`Query` everything:** callers can *detect* an OK packet (zero columns) but not read its counts. A "last counts" getter on the conn would be valid only until the driver's next wire command — and `database/sql` issues commands the caller didn't write - i.e. the `LAST_INSERT_ID()` footgun rebuilt client-side. Attaching a `Result` to the rows costs the same new Raw-reachable interface as this proposal, with worse ergonomics: every INSERT returns an open cursor that pins the connection until `Close`.

There is no zero-new-API way to close the gap; the question is only the shape. Returning rows-or-result from the call that produced them beats mutable conn state or overloaded streaming semantics.

### Why in this driver rather than database/sql

Same reason as #1309: `database/sql` can't carry driver-specific results, and `Conn.Raw` is the stdlib's sanctioned escape hatch (golang/go#5606 → `Raw` in Go 1.13). The Exec/Query split also encodes connection lifetime in the pooled API (`Exec` releases the conn, `Query` pins it until `rows.Close()`), so a rows-or-result call only fits on a raw conn the caller already holds. #180 and #971 are the same underlying gap and predate `Raw`; #1179 is still open.

### Implementation

If there's appetite for this, I'm happy to submit a PR.

WIP branch @ https://github.com/morgo/mysql/tree/query-result-context

Contributor guide

Open the contributing guide

Research direction

Start with the existing mysql.Result work referenced by #1261 and #1309, then inspect the database/sql Conn.Raw path and the driver's QueryContext, ExecContext, and readUntilEOF handling. Compare the WIP query-result-context branch with the proposal; done means a raw connection can return either rows or the OK-packet result in one round trip without discarding either response.

Written by the indexing model from the issue text.

Assessment

Tech stack
go, mysql
Domain
backend-api-design, database
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Stale
Clarity
Clearly specified
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.