From ab1062c4461cb1142b875e1b92d9d30688d67e8d Mon Sep 17 00:00:00 2001 From: Zach Sierakowski Date: Tue, 28 Oct 2025 16:08:15 -0400 Subject: [PATCH 1/2] feat: Expose scanning any/all without enforcing struct * Exposes scanAll and scanAny functionality to support multi-statement abstractions * improve diff readability * update func name * fix tests * Tidy comment * improve test --- named.go | 2 +- named_context.go | 2 +- sqlx.go | 37 ++-- sqlx_context.go | 2 +- sqlx_context_test.go | 2 +- sqlx_test.go | 408 ++++++++++++++++++++++++++++++++++++++++++- 6 files changed, 435 insertions(+), 18 deletions(-) diff --git a/named.go b/named.go index 6ac44777..36c18b44 100644 --- a/named.go +++ b/named.go @@ -105,7 +105,7 @@ func (n *NamedStmt) Select(dest interface{}, arg interface{}) error { } // if something happens here, we want to make sure the rows are Closed defer rows.Close() - return scanAll(rows, dest, false) + return ScanAll(rows, dest, false) } // Get using this NamedStmt diff --git a/named_context.go b/named_context.go index 9ad23f4e..18847aa5 100644 --- a/named_context.go +++ b/named_context.go @@ -100,7 +100,7 @@ func (n *NamedStmt) SelectContext(ctx context.Context, dest interface{}, arg int } // if something happens here, we want to make sure the rows are Closed defer rows.Close() - return scanAll(rows, dest, false) + return ScanAll(rows, dest, false) } // GetContext using this NamedStmt diff --git a/sqlx.go b/sqlx.go index 8259a4fe..ad9a41aa 100644 --- a/sqlx.go +++ b/sqlx.go @@ -680,7 +680,7 @@ func Select(q Queryer, dest interface{}, query string, args ...interface{}) erro } // if something happens here, we want to make sure the rows are Closed defer rows.Close() - return scanAll(rows, dest, false) + return ScanAll(rows, dest, false) } // Get does a QueryRow using the provided Queryer, and scans the resulting row @@ -746,7 +746,15 @@ func (r *Row) scanAny(dest interface{}, structOnly bool) error { return r.err } defer r.rows.Close() + return ScanSingleRow(r, dest, structOnly) +} +// ScanSingleRow scans a single Row into the dest. +// +// It assumes that r is positioned on a valid row. +// If structOnly is true, an error will be returned if dest is a scannable type (for backwards compatibility with StructScan). +// Callers are responsible for closing r. +func ScanSingleRow(r ColScanner, dest interface{}, structOnly bool) error { v := reflect.ValueOf(dest) if v.Kind() != reflect.Ptr { return errors.New("must pass a pointer, not a value, to StructScan destination") @@ -775,11 +783,18 @@ func (r *Row) scanAny(dest interface{}, structOnly bool) error { return r.Scan(dest) } - m := r.Mapper - + var m *reflectx.Mapper + switch rows := r.(type) { + case *Rows: + m = rows.Mapper + case *Row: + m = rows.Mapper + default: + m = mapper() + } fields := m.TraversalsByName(v.Type(), columns) // if we are not unsafe and are missing fields, return an error - if f, err := missingFields(fields); err != nil && !r.unsafe { + if f, err := missingFields(fields); err != nil && !isUnsafe(r) { return fmt.Errorf("missing destination name %s in %T", columns[f], dest) } values := make([]interface{}, len(columns)) @@ -880,7 +895,7 @@ func structOnlyError(t reflect.Type) error { return fmt.Errorf("expected a struct, but struct %s has no exported fields", t.Name()) } -// scanAll scans all rows into a destination, which must be a slice of any +// ScanAll scans all rows into a destination, which must be a slice of any // type. It resets the slice length to zero before appending each element to // the slice. If the destination slice type is a Struct, then StructScan will // be used on each row. If the destination is some other kind of base type, @@ -889,14 +904,10 @@ func structOnlyError(t reflect.Type) error { // // rows, _ := db.Query("select id from people;") // var ids []int -// scanAll(rows, &ids, false) +// ScanAll(rows, &ids, false) // -// and ids will be a list of the id results. I realize that this is a desirable -// interface to expose to users, but for now it will only be exposed via changes -// to `Get` and `Select`. The reason that this has been implemented like this is -// this is the only way to not duplicate reflect work in the new API while -// maintaining backwards compatibility. -func scanAll(rows rowsi, dest interface{}, structOnly bool) error { +// and ids will be a list of the id results. +func ScanAll(rows rowsi, dest interface{}, structOnly bool) error { var v, vp reflect.Value value := reflect.ValueOf(dest) @@ -1003,7 +1014,7 @@ func scanAll(rows rowsi, dest interface{}, structOnly bool) error { // allocate structs for the entire result, use Queryx and see sqlx.Rows.StructScan. // If rows is sqlx.Rows, it will use its mapper, otherwise it will use the default. func StructScan(rows rowsi, dest interface{}) error { - return scanAll(rows, dest, true) + return ScanAll(rows, dest, true) } diff --git a/sqlx_context.go b/sqlx_context.go index 32621d56..b903a5b1 100644 --- a/sqlx_context.go +++ b/sqlx_context.go @@ -59,7 +59,7 @@ func SelectContext(ctx context.Context, q QueryerContext, dest interface{}, quer } // if something happens here, we want to make sure the rows are Closed defer rows.Close() - return scanAll(rows, dest, false) + return ScanAll(rows, dest, false) } // PreparexContext prepares a statement. diff --git a/sqlx_context_test.go b/sqlx_context_test.go index 91c5cba1..bb0cbd56 100644 --- a/sqlx_context_test.go +++ b/sqlx_context_test.go @@ -1052,7 +1052,7 @@ func TestUsageContext(t *testing.T) { if err != nil { t.Error(err) } - err = scanAll(rows, &sdest, false) + err = ScanAll(rows, &sdest, false) if err != nil { t.Error(err) } diff --git a/sqlx_test.go b/sqlx_test.go index 9fac2cd4..66033a2b 100644 --- a/sqlx_test.go +++ b/sqlx_test.go @@ -1235,7 +1235,7 @@ func TestUsage(t *testing.T) { if err != nil { t.Error(err) } - err = scanAll(rows, &sdest, false) + err = ScanAll(rows, &sdest, false) if err != nil { t.Error(err) } @@ -1923,3 +1923,409 @@ func TestSelectReset(t *testing.T) { } }) } + +func TestScanSingleRowStruct(t *testing.T) { + RunWithSchema(defaultSchema, t, func(db *DB, t *testing.T, now string) { + loadDefaultFixture(db, t) + + // Test scanning into a struct + rows, err := db.Queryx("SELECT first_name, last_name, email FROM person WHERE first_name = 'Jason'") + if err != nil { + t.Fatal(err) + } + defer rows.Close() + + if !rows.Next() { + t.Fatal("Expected at least one row") + } + + var p Person + err = ScanSingleRow(rows, &p, false) + if err != nil { + t.Errorf("ScanSingleRow failed: %v", err) + } + + if p.FirstName != "Jason" { + t.Errorf("Expected FirstName to be 'Jason', got '%s'", p.FirstName) + } + if p.LastName != "Moiron" { + t.Errorf("Expected LastName to be 'Moiron', got '%s'", p.LastName) + } + if p.Email != "jmoiron@jmoiron.net" { + t.Errorf("Expected Email to be 'jmoiron@jmoiron.net', got '%s'", p.Email) + } + }) +} + +func TestScanSingleRowScannableType(t *testing.T) { + RunWithSchema(defaultSchema, t, func(db *DB, t *testing.T, now string) { + loadDefaultFixture(db, t) + + // Test scanning into a scannable type (single column) + rows, err := db.Queryx("SELECT first_name FROM person WHERE first_name = 'Jason'") + if err != nil { + t.Fatal(err) + } + + if !rows.Next() { + rows.Close() + t.Fatal("Expected at least one row") + } + + var firstName string + err = ScanSingleRow(rows, &firstName, false) + rows.Close() + if err != nil { + t.Errorf("ScanSingleRow failed: %v", err) + } + + if firstName != "Jason" { + t.Errorf("Expected firstName to be 'Jason', got '%s'", firstName) + } + + // Test scanning another string column + rows2, err := db.Queryx("SELECT last_name FROM person WHERE first_name = 'Jason'") + if err != nil { + t.Fatal(err) + } + + if !rows2.Next() { + rows2.Close() + t.Fatal("Expected at least one row") + } + + var lastName string + err = ScanSingleRow(rows2, &lastName, false) + rows2.Close() + if err != nil { + t.Errorf("ScanSingleRow failed: %v", err) + } + + if lastName != "Moiron" { + t.Errorf("Expected lastName to be 'Moiron', got '%s'", lastName) + } + }) +} + +func TestScanSingleRowErrors(t *testing.T) { + RunWithSchema(defaultSchema, t, func(db *DB, t *testing.T, now string) { + loadDefaultFixture(db, t) + + rows, err := db.Queryx("SELECT first_name FROM person WHERE first_name = 'Jason'") + if err != nil { + t.Fatal(err) + } + + if !rows.Next() { + rows.Close() + t.Fatal("Expected at least one row") + } + + // Test non-pointer destination + var name string + err = ScanSingleRow(rows, name, false) + if err == nil { + t.Error("Expected error when passing non-pointer destination, but got nil") + } + if !strings.Contains(err.Error(), "must pass a pointer") { + t.Errorf("Expected error about non-pointer, got: %v", err) + } + + // Test nil pointer destination + err = ScanSingleRow(rows, (*string)(nil), false) + rows.Close() + if err == nil { + t.Error("Expected error when passing nil pointer, but got nil") + } + if !strings.Contains(err.Error(), "nil pointer passed") { + t.Errorf("Expected error about nil pointer, got: %v", err) + } + + // Test scannable type with multiple columns + rows2, err := db.Queryx("SELECT first_name, last_name FROM person WHERE first_name = 'Jason'") + if err != nil { + t.Fatal(err) + } + + if !rows2.Next() { + rows2.Close() + t.Fatal("Expected at least one row") + } + + var singleString string + err = ScanSingleRow(rows2, &singleString, false) + rows2.Close() + if err == nil { + t.Error("Expected error when scanning multiple columns into single scannable type, but got nil") + } + if !strings.Contains(err.Error(), "with >1 columns") { + t.Errorf("Expected error about multiple columns, got: %v", err) + } + + // Test missing fields in struct (this may or may not error depending on unsafe mode) + type PartialPerson struct { + FirstName string `db:"first_name"` + // Missing last_name field + } + + rows3, err := db.Queryx("SELECT first_name, last_name FROM person WHERE first_name = 'Jason'") + if err != nil { + t.Fatal(err) + } + + if !rows3.Next() { + rows3.Close() + t.Fatal("Expected at least one row") + } + + var pp PartialPerson + err = ScanSingleRow(rows3, &pp, false) + rows3.Close() + // This should work in unsafe mode, but may error in safe mode depending on implementation + // We'll just verify that if there's an error, it's about missing fields + if err != nil && !strings.Contains(err.Error(), "missing destination name") { + t.Errorf("Unexpected error type: %v", err) + } + }) +} + +func TestScanSingleRowStructOnly(t *testing.T) { + RunWithSchema(defaultSchema, t, func(db *DB, t *testing.T, now string) { + loadDefaultFixture(db, t) + + // Test structOnly=true with struct - should succeed + rows, err := db.Queryx("SELECT first_name, last_name FROM person WHERE first_name = 'Jason'") + if err != nil { + t.Fatal(err) + } + + if !rows.Next() { + rows.Close() + t.Fatal("Expected at least one row") + } + + var p Person + err = ScanSingleRow(rows, &p, true) + rows.Close() + if err != nil { + t.Errorf("ScanSingleRow with structOnly=true should work for struct, got error: %v", err) + } + + if p.FirstName != "Jason" { + t.Errorf("Expected FirstName to be 'Jason', got '%s'", p.FirstName) + } + + // Test structOnly=true with scannable type - should error + rows2, err := db.Queryx("SELECT first_name FROM person WHERE first_name = 'Jason'") + if err != nil { + t.Fatal(err) + } + + if !rows2.Next() { + rows2.Close() + t.Fatal("Expected at least one row") + } + + var name string + err = ScanSingleRow(rows2, &name, true) + rows2.Close() + if err == nil { + t.Error("Expected error when using structOnly=true with scannable type, but got nil") + } + // The error should be a structOnly error + if !strings.Contains(err.Error(), "expected struct but got") { + t.Errorf("Expected structOnly error, got: %v", err) + } + + // Test structOnly=false with scannable type - should succeed + rows3, err := db.Queryx("SELECT first_name FROM person WHERE first_name = 'Jason'") + if err != nil { + t.Fatal(err) + } + + if !rows3.Next() { + rows3.Close() + t.Fatal("Expected at least one row") + } + + var name2 string + err = ScanSingleRow(rows3, &name2, false) + rows3.Close() + if err != nil { + t.Errorf("ScanSingleRow with structOnly=false should work for scannable type, got error: %v", err) + } + + if name2 != "Jason" { + t.Errorf("Expected name2 to be 'Jason', got '%s'", name2) + } + }) +} + +func TestScanSingleRowWithQueryRowx(t *testing.T) { + RunWithSchema(defaultSchema, t, func(db *DB, t *testing.T, now string) { + loadDefaultFixture(db, t) + + // Test scanning into a struct using QueryRowx + row := db.QueryRowx("SELECT first_name, last_name, email FROM person WHERE first_name = 'Jason'") + + var p Person + err := ScanSingleRow(row, &p, false) + if err != nil { + t.Errorf("ScanSingleRow with QueryRowx failed: %v", err) + } + + if p.FirstName != "Jason" { + t.Errorf("Expected FirstName to be 'Jason', got '%s'", p.FirstName) + } + if p.LastName != "Moiron" { + t.Errorf("Expected LastName to be 'Moiron', got '%s'", p.LastName) + } + if p.Email != "jmoiron@jmoiron.net" { + t.Errorf("Expected Email to be 'jmoiron@jmoiron.net', got '%s'", p.Email) + } + + // Test scanning into a scannable type using QueryRowx + row2 := db.QueryRowx("SELECT first_name FROM person WHERE first_name = 'John'") + + var firstName string + err = ScanSingleRow(row2, &firstName, false) + if err != nil { + t.Errorf("ScanSingleRow with QueryRowx for scannable type failed: %v", err) + } + + if firstName != "John" { + t.Errorf("Expected firstName to be 'John', got '%s'", firstName) + } + }) +} + +func TestScanSingleRowWithQueryRowxNonPointer(t *testing.T) { + RunWithSchema(defaultSchema, t, func(db *DB, t *testing.T, now string) { + loadDefaultFixture(db, t) + + // Test non-pointer destination with QueryRowx + row := db.QueryRowx("SELECT first_name FROM person WHERE first_name = 'Jason'") + + var name string + err := ScanSingleRow(row, name, false) + if err == nil { + t.Error("Expected error when passing non-pointer destination to QueryRowx ScanSingleRow, but got nil") + } + if !strings.Contains(err.Error(), "must pass a pointer") { + t.Errorf("Expected error about non-pointer with QueryRowx, got: %v", err) + } + }) +} + +func TestScanSingleRowWithQueryRowxNilPointer(t *testing.T) { + RunWithSchema(defaultSchema, t, func(db *DB, t *testing.T, now string) { + loadDefaultFixture(db, t) + + // Test nil pointer destination with QueryRowx + row := db.QueryRowx("SELECT first_name FROM person WHERE first_name = 'Jason'") + + err := ScanSingleRow(row, (*string)(nil), false) + if err == nil { + t.Error("Expected error when passing nil pointer to QueryRowx ScanSingleRow, but got nil") + } + if !strings.Contains(err.Error(), "nil pointer passed") { + t.Errorf("Expected error about nil pointer with QueryRowx, got: %v", err) + } + }) +} + +func TestScanSingleRowWithQueryRowxMultipleColumns(t *testing.T) { + RunWithSchema(defaultSchema, t, func(db *DB, t *testing.T, now string) { + loadDefaultFixture(db, t) + + // Test scannable type with multiple columns using QueryRowx + row := db.QueryRowx("SELECT first_name, last_name FROM person WHERE first_name = 'Jason'") + + var singleString string + err := ScanSingleRow(row, &singleString, false) + if err == nil { + t.Error("Expected error when scanning multiple columns into single scannable type with QueryRowx, but got nil") + } + if !strings.Contains(err.Error(), "with >1 columns") { + t.Errorf("Expected error about multiple columns with QueryRowx, got: %v", err) + } + }) +} + +func TestScanSingleRowWithQueryRowxStructOnlyTrue(t *testing.T) { + RunWithSchema(defaultSchema, t, func(db *DB, t *testing.T, now string) { + loadDefaultFixture(db, t) + + // Test structOnly=true with struct using QueryRowx - should succeed + row := db.QueryRowx("SELECT first_name, last_name FROM person WHERE first_name = 'Jason'") + + var p Person + err := ScanSingleRow(row, &p, true) + if err != nil { + t.Errorf("ScanSingleRow with QueryRowx and structOnly=true should work for struct, got error: %v", err) + } + + if p.FirstName != "Jason" { + t.Errorf("Expected FirstName to be 'Jason', got '%s'", p.FirstName) + } + if p.LastName != "Moiron" { + t.Errorf("Expected LastName to be 'Moiron', got '%s'", p.LastName) + } + }) +} + +func TestScanSingleRowWithQueryRowxStructOnlyError(t *testing.T) { + RunWithSchema(defaultSchema, t, func(db *DB, t *testing.T, now string) { + loadDefaultFixture(db, t) + + // Test structOnly=true with scannable type using QueryRowx - should error + row := db.QueryRowx("SELECT first_name FROM person WHERE first_name = 'Jason'") + + var name string + err := ScanSingleRow(row, &name, true) + if err == nil { + t.Error("Expected error when using structOnly=true with scannable type and QueryRowx, but got nil") + } + // The error should be a structOnly error + if !strings.Contains(err.Error(), "expected struct but got") { + t.Errorf("Expected structOnly error with QueryRowx, got: %v", err) + } + }) +} + +func TestScanSingleRowWithQueryRowxStructOnlyFalse(t *testing.T) { + RunWithSchema(defaultSchema, t, func(db *DB, t *testing.T, now string) { + loadDefaultFixture(db, t) + + // Test structOnly=false with scannable type using QueryRowx - should succeed + row := db.QueryRowx("SELECT first_name FROM person WHERE first_name = 'John'") + + var name string + err := ScanSingleRow(row, &name, false) + if err != nil { + t.Errorf("ScanSingleRow with QueryRowx and structOnly=false should work for scannable type, got error: %v", err) + } + + if name != "John" { + t.Errorf("Expected name to be 'John', got '%s'", name) + } + }) +} + +func TestScanSingleRowWithQueryRowxNoRows(t *testing.T) { + RunWithSchema(defaultSchema, t, func(db *DB, t *testing.T, now string) { + loadDefaultFixture(db, t) + + // Test QueryRowx with no matching rows - should return sql.ErrNoRows + row := db.QueryRowx("SELECT first_name FROM person WHERE first_name = 'NonExistent'") + + var name string + err := ScanSingleRow(row, &name, false) + if err == nil { + t.Error("Expected sql.ErrNoRows when no rows match QueryRowx, but got nil") + } + if err.Error() != "sql: no rows in result set" { + t.Errorf("Expected sql.ErrNoRows with QueryRowx, got: %v", err) + } + }) +} From 7a5bd1724854e4ca21ca9bb3b9c8dea738e222c0 Mon Sep 17 00:00:00 2001 From: Zach Sierakowski Date: Fri, 21 Nov 2025 09:58:20 -0500 Subject: [PATCH 2/2] Update sqlx.go Co-authored-by: Johanan --- sqlx.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/sqlx.go b/sqlx.go index ad9a41aa..1b9ed604 100644 --- a/sqlx.go +++ b/sqlx.go @@ -785,9 +785,7 @@ func ScanSingleRow(r ColScanner, dest interface{}, structOnly bool) error { var m *reflectx.Mapper switch rows := r.(type) { - case *Rows: - m = rows.Mapper - case *Row: + case *Rows, *Row: m = rows.Mapper default: m = mapper()