Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dbsqlrows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ rows, err := db.QueryContext(ctx, q, opts) // ExecOptions is the first query arg
result, err := dbsqlrows.WriteRows(rows, w, dbsqlrows.SQLRowsConfig{})
```

`WriteRows` and `RunRows` return `ErrMissingMetadataRow` when that pseudo-row is absent. `ReadMetadataAndAdvanceToData` uses the other policy: the same absence is end of batch (`ok == false`, nil error). `RunRowsAtData` rejects metadata whose field count does not match `Rows.Columns`.
`WriteRows` and `RunRows` return `ErrMissingMetadataRow` when that pseudo-row is absent. `ReadMetadataAndAdvanceToData` uses the other policy: the same absence is end of batch (`ok == false`, nil error). `RunRowsAtData` rejects metadata whose field count does not match `Rows.Columns`, except the go-sql-spanner no-row result: zero metadata fields, one `affected_rows` column, and no data rows.

Option B: nested module [`gospanner/`](gospanner/README.md) provides `DefaultExecOptions` and `QueryExport` for one-shot query → csv/jsonl export when the app already depends on go-sql-spanner. It is a thin reference integration (`ExecOptions` + `QueryContext` + `WriteRows`); root `go.mod` still has no go-sql-spanner. Interactive shells, metadata-first batches, EXPLAIN, and per-query driver options (`QueryMode`, `DirectExecuteQuery`) should use Option A with app-owned `ExecOptions` instead — validated by [spannersh](https://github.com/apstndb/spannersh).

Expand Down
3 changes: 3 additions & 0 deletions dbsqlrows/coverage_213_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ func (f *scanFailFacade) scan(...any) error { return errScan }
func (f *scanFailFacade) columnCount() (int, error) {
return f.columns, nil
}
func (f *scanFailFacade) columnNames() ([]string, error) {
return make([]string, f.columns), nil
}
func (f *scanFailFacade) err() error { return nil }

func TestErrMissingMetadataRow(t *testing.T) {
Expand Down
96 changes: 96 additions & 0 deletions dbsqlrows/ddl_empty_rows_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package dbsqlrows

import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"io"
"testing"

"cloud.google.com/go/spanner"
sppb "cloud.google.com/go/spanner/apiv1/spannerpb"
)

// This models go-sql-spanner's emptyRows data phase without a Spanner service:
// zero metadata fields, a synthetic affected_rows column, and immediate EOF.
type ddlEmptyConnector struct{}

func (ddlEmptyConnector) Connect(context.Context) (driver.Conn, error) { return ddlEmptyConn{}, nil }
func (ddlEmptyConnector) Driver() driver.Driver { return ddlEmptyDriver{} }

type ddlEmptyDriver struct{}

func (ddlEmptyDriver) Open(string) (driver.Conn, error) { return ddlEmptyConn{}, nil }

type ddlEmptyConn struct{}

func (ddlEmptyConn) Prepare(string) (driver.Stmt, error) {
return nil, errors.New("prepare not used")
}
func (ddlEmptyConn) Close() error { return nil }
func (ddlEmptyConn) Begin() (driver.Tx, error) {
return nil, errors.New("transaction not used")
}
func (ddlEmptyConn) QueryContext(context.Context, string, []driver.NamedValue) (driver.Rows, error) {
return &ddlEmptyRows{}, nil
}

type ddlEmptyRows struct{ set int }

func (r *ddlEmptyRows) Columns() []string {
if r.set == 0 {
return []string{"affected_rows"}
}
return []string{"stats"}
}
func (*ddlEmptyRows) Close() error { return nil }
func (*ddlEmptyRows) Next([]driver.Value) error { return io.EOF }
func (r *ddlEmptyRows) HasNextResultSet() bool { return r.set == 0 }
func (r *ddlEmptyRows) NextResultSet() error {
if !r.HasNextResultSet() {
return io.EOF
}
r.set++
return nil
}

func TestRunRowsAtDataDDLEmptyRows(t *testing.T) {
t.Parallel()

db := sql.OpenDB(ddlEmptyConnector{})
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Error(err)
}
})
rows, err := db.QueryContext(t.Context(), "CREATE TABLE t (id INT64) PRIMARY KEY (id)")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := rows.Close(); err != nil {
t.Error(err)
}
})

var prepared, written, finished int
got, err := RunRowsAtData(rows, metadataWithNames(), SQLRowsHooks{
PrepareMetadata: func(*sppb.ResultSetMetadata) error { prepared++; return nil },
WriteDataRow: func([]spanner.GenericColumnValue) error { written++; return nil },
Finish: func(*SQLRowsResult) error { finished++; return nil },
}, SQLRowsConfig{})
if err != nil {
t.Fatal(err)
}
if got.RowsRead != 0 || prepared != 1 || written != 0 || finished != 1 {
t.Fatalf("result=%#v prepared=%d written=%d finished=%d", got, prepared, written, finished)
}
if !rows.NextResultSet() {
t.Fatalf("stats result set unavailable after DDL data phase: %v", rows.Err())
}
columns, err := rows.Columns()
if err != nil || len(columns) != 1 || columns[0] != "stats" {
t.Fatalf("stats columns = %q, %v", columns, err)
}
}
39 changes: 28 additions & 11 deletions dbsqlrows/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ var (
// and returns ok=false with a nil error.
ErrMissingMetadataRow = errors.New("missing result set metadata row: enable driver metadata (go-sql-spanner ReturnResultSetMetadata and proto decode)")
// ErrMetadataColumnCount reports that metadata field count does not match
// [*sql.Rows.Columns] at prepare time.
// [*sql.Rows.Columns] at prepare time, except for an empty affected_rows
// result set produced by go-sql-spanner for a statement without query rows.
ErrMetadataColumnCount = errors.New("result set metadata field count does not match column count")
// ErrMissingDataResultSet reports that NextResultSet did not advance to the
// data rows result set after the metadata pseudo-row.
Expand Down Expand Up @@ -179,15 +180,18 @@ func runRows(fac rowsFacade, hooks SQLRowsHooks, run sqlRowsRunConfig) (*SQLRows
return nil, ErrNilMetadata
}

if err := checkMetadataColumnCount(fac, run.metadata); err != nil {
skipDataRows, err := checkMetadataColumnCount(fac, run.metadata)
if err != nil {
return abort(err)
}
if err := callPrepareMetadata(hooks, run.metadata); err != nil {
return abort(err)
}

if err := processDataRows(fac, hooks, result); err != nil {
return abort(err)
if !skipDataRows {
if err := processDataRows(fac, hooks, result); err != nil {
return abort(err)
}
}
if err := fac.err(); err != nil {
return abort(err)
Expand Down Expand Up @@ -289,19 +293,32 @@ func finishRun(result *SQLRowsResult, hooks SQLRowsHooks) (*SQLRowsResult, error
return result, nil
}

func checkMetadataColumnCount(fac rowsFacade, md *sppb.ResultSetMetadata) error {
// The driver's no-row result uses a synthetic affected_rows column even though
// its metadata row type is empty. Probe only this exact shape: an actual data
// row remains a mismatch, and clean EOF must not be read a second time.
func checkMetadataColumnCount(fac rowsFacade, md *sppb.ResultSetMetadata) (skipDataRows bool, err error) {
if md == nil {
return nil
return false, nil
}
n, err := fac.columnCount()
columns, err := fac.columnNames()
if err != nil {
return err
return false, err
}
fields := len(md.GetRowType().GetFields())
if fields != n {
return fmt.Errorf("%w: metadata fields %d, columns %d", ErrMetadataColumnCount, fields, n)
if fields == len(columns) {
return false, nil
}
return nil
mismatch := fmt.Errorf("%w: metadata fields %d, columns %d", ErrMetadataColumnCount, fields, len(columns))
if fields != 0 || len(columns) != 1 || columns[0] != "affected_rows" {
return false, mismatch
}
if fac.next() {
return false, mismatch
}
if err := fac.err(); err != nil {
return false, err
}
return true, nil
}

func prepareWriterMetadata(w GCVStreamWriter, md *sppb.ResultSetMetadata) error {
Expand Down
100 changes: 82 additions & 18 deletions dbsqlrows/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,27 +19,29 @@ import (
var _ rowsFacade = (*stubSQLRows)(nil)

type stubSQLRows struct {
resultSets [][]stubRow
set int
row int
scanErr error
nextErr error
nextErrOn int
nextCalls int
nextRSErr error
nextRSErrOn int
nextRSCalls int
lastErr error
nextRSOK bool
columns []string
columnErr error
resultSets [][]stubRow
set int
row int
scanErr error
nextErr error
nextErrOn int
nextCalls int
nextRSErr error
nextRSErrOn int
nextRSCalls int
lastErr error
nextRSOK bool
columns []string
columnErr error
nextAttempts int
}

type stubRow struct {
values []any
}

func (s *stubSQLRows) next() bool {
s.nextAttempts++
if s.scanErr != nil {
return false
}
Expand Down Expand Up @@ -118,16 +120,21 @@ func (s *stubSQLRows) scan(dest ...any) error {
}

func (s *stubSQLRows) columnCount() (int, error) {
cols, err := s.columnNames()
return len(cols), err
}

func (s *stubSQLRows) columnNames() ([]string, error) {
if s.columnErr != nil {
return 0, s.columnErr
return nil, s.columnErr
}
if len(s.columns) > 0 {
return len(s.columns), nil
return s.columns, nil
}
if s.set >= len(s.resultSets) || len(s.resultSets[s.set]) == 0 {
return 0, nil
return nil, nil
}
return len(s.resultSets[s.set][0].values), nil
return make([]string, len(s.resultSets[s.set][0].values)), nil
}

func (s *stubSQLRows) err() error {
Expand Down Expand Up @@ -224,6 +231,63 @@ func TestMetadataColumnCount(t *testing.T) {
}
})

t.Run("DDL affected_rows placeholder has no data", func(t *testing.T) {
t.Parallel()
stub := &stubSQLRows{columns: []string{"affected_rows"}, resultSets: [][]stubRow{{}}}
var prepared, written, finished int
got, err := runRows(stub, SQLRowsHooks{
PrepareMetadata: func(*sppb.ResultSetMetadata) error { prepared++; return nil },
WriteDataRow: func([]spanner.GenericColumnValue) error { written++; return nil },
Finish: func(*SQLRowsResult) error { finished++; return nil },
}, sqlRowsRunConfig{metadata: metadataWithNames()})
if err != nil {
t.Fatal(err)
}
if got.RowsRead != 0 || prepared != 1 || written != 0 || finished != 1 || stub.nextAttempts != 1 {
t.Fatalf("result=%#v prepared=%d written=%d finished=%d nextAttempts=%d", got, prepared, written, finished, stub.nextAttempts)
}
})

t.Run("affected_rows with an actual row is a mismatch", func(t *testing.T) {
t.Parallel()
stub := &stubSQLRows{
columns: []string{"affected_rows"},
resultSets: [][]stubRow{{{values: []any{gcvctor.Int64Value(1)}}}},
}
var prepared, written, finished int
got, err := runRows(stub, SQLRowsHooks{
PrepareMetadata: func(*sppb.ResultSetMetadata) error { prepared++; return nil },
WriteDataRow: func([]spanner.GenericColumnValue) error { written++; return nil },
Finish: func(*SQLRowsResult) error { finished++; return nil },
}, sqlRowsRunConfig{metadata: metadataWithNames()})
if !errors.Is(err, ErrMetadataColumnCount) || got == nil || got.RowsRead != 0 || prepared != 0 || written != 0 || finished != 0 || stub.nextCalls != 1 {
t.Fatalf("result=%#v err=%v prepared=%d written=%d finished=%d nextCalls=%d", got, err, prepared, written, finished, stub.nextCalls)
}
})

t.Run("different empty one-column result is a mismatch", func(t *testing.T) {
t.Parallel()
stub := &stubSQLRows{columns: []string{"id"}, resultSets: [][]stubRow{{}}}
assertColumnMismatch(t, stub, sqlRowsRunConfig{metadata: metadataWithNames()}, metadataWithNames(), 0)
if stub.nextAttempts != 0 {
t.Fatalf("Next attempts = %d, want zero", stub.nextAttempts)
}
})

t.Run("placeholder read error is retained", func(t *testing.T) {
t.Parallel()
readErr := errors.New("next failed")
stub := &stubSQLRows{columns: []string{"affected_rows"}, resultSets: [][]stubRow{{}}, lastErr: readErr}
var prepared, finished int
got, err := runRows(stub, SQLRowsHooks{
PrepareMetadata: func(*sppb.ResultSetMetadata) error { prepared++; return nil },
Finish: func(*SQLRowsResult) error { finished++; return nil },
}, sqlRowsRunConfig{metadata: metadataWithNames()})
if !errors.Is(err, readErr) || got == nil || got.RowsRead != 0 || prepared != 0 || finished != 0 {
t.Fatalf("result=%#v err=%v prepared=%d finished=%d", got, err, prepared, finished)
}
})

t.Run("column count error is preserved", func(t *testing.T) {
t.Parallel()
stub := &stubSQLRows{columnErr: columnErr}
Expand Down
4 changes: 3 additions & 1 deletion dbsqlrows/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,9 @@ func RunRows(rows *sql.Rows, hooks SQLRowsHooks, cfg SQLRowsConfig) (*SQLRowsRes

// RunRowsAtData streams rows already positioned on the data result set using hooks.
// metadata must be non-nil. Its field count must match [*sql.Rows.Columns];
// otherwise the error is [ErrMetadataColumnCount].
// otherwise the error is [ErrMetadataColumnCount]. The one exception is a
// zero-field metadata result with a single affected_rows column and no data
// rows, as returned by go-sql-spanner for statements without query rows.
// See [WriteRowsAtData] for stats and partial-result semantics.
func RunRowsAtData(
rows *sql.Rows,
Expand Down
7 changes: 6 additions & 1 deletion dbsqlrows/rows_facade.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ type rowsFacade interface {
next() bool
nextResultSet() bool
scan(dest ...any) error
columnNames() ([]string, error)
columnCount() (int, error)
err() error
}
Expand All @@ -28,8 +29,12 @@ func (f sqlRowsFacade) scan(dest ...any) error {
return f.Scan(dest...)
}

func (f sqlRowsFacade) columnNames() ([]string, error) {
return f.Columns()
}

func (f sqlRowsFacade) columnCount() (int, error) {
cols, err := f.Columns()
cols, err := f.columnNames()
if err != nil {
return 0, err
}
Expand Down
Loading