From 9e80321c3e25b93fb70a9797b86139dd874a2e13 Mon Sep 17 00:00:00 2001 From: CyJay Date: Thu, 14 May 2026 17:45:47 +0800 Subject: [PATCH 1/4] feat: no ErrNoRows when scan nil struct ptr --- internal/dbtest/db_test.go | 46 ++++++++++++++++++++++++++++++++++++++ model.go | 7 ++++-- query_base.go | 3 +++ 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/internal/dbtest/db_test.go b/internal/dbtest/db_test.go index e4266e2ea..802b20c12 100644 --- a/internal/dbtest/db_test.go +++ b/internal/dbtest/db_test.go @@ -262,6 +262,7 @@ func TestDB(t *testing.T) { {testSelectMap}, {testSelectMapSlice}, {testSelectStruct}, + {testSelectStructNilPtr}, {testSelectNestedStructValue}, {testSelectNestedStructPtr}, {testSelectStructSlice}, @@ -444,6 +445,51 @@ func testSelectStruct(t *testing.T, db *bun.DB) { require.Contains(t, err.Error(), "Model does not have column") } +func testSelectStructNilPtr(t *testing.T, db *bun.DB) { + type Model struct { + Num int + Str string + } + + // Scan a row into a nil **Model: the inner pointer should be allocated and populated. + var model *Model + err := db.NewSelect(). + ColumnExpr("10 AS num, 'hello' AS str"). + Scan(ctx, &model) + require.NoError(t, err) + require.NotNil(t, model) + require.Equal(t, 10, model.Num) + require.Equal(t, "hello", model.Str) + + // No rows + nil **Model: should NOT return ErrNoRows; inner pointer stays nil. + var empty *Model + err = db.NewSelect(). + TableExpr("(SELECT 42 AS num) AS t"). + Where("1 = 2"). + Scan(ctx, &empty) + require.NoError(t, err) + require.Nil(t, empty) + + // Regression: a non-nil *Model (existing behaviour) must still return ErrNoRows. + nonNil := new(Model) + err = db.NewSelect(). + TableExpr("(SELECT 42 AS num) AS t"). + Where("1 = 2"). + Scan(ctx, nonNil) + require.Equal(t, sql.ErrNoRows, err) + + // Scan a row into a non-nil **Model: the existing inner pointer is reused. + existing := &Model{} + preserved := existing + err = db.NewSelect(). + ColumnExpr("7 AS num, 'world' AS str"). + Scan(ctx, &existing) + require.NoError(t, err) + require.Same(t, preserved, existing) + require.Equal(t, 7, existing.Num) + require.Equal(t, "world", existing.Str) +} + func testSelectNestedStructValue(t *testing.T, db *bun.DB) { type Model struct { Num int diff --git a/model.go b/model.go index cec755ef9..73f3b4e94 100644 --- a/model.go +++ b/model.go @@ -124,6 +124,10 @@ func _newModel(db *DB, dest any, scan bool) (Model, error) { return newMapModel(db, mapPtr), nil case reflect.Struct: return newStructTableModelValue(db, dest, v), nil + case reflect.Pointer: + if v.Type().Elem().Kind() == reflect.Struct { + return newStructTableModelValue(db, dest, v), nil + } case reflect.Slice: switch elemType := sliceElemType(v); elemType.Kind() { case reflect.Struct: @@ -200,8 +204,7 @@ func validMap(typ reflect.Type) error { func isSingleRowModel(m Model) bool { switch m.(type) { - case *mapModel, - *structTableModel, + case *structTableModel, *scanModel: return true default: diff --git a/query_base.go b/query_base.go index 09c015395..7cd056c1c 100644 --- a/query_base.go +++ b/query_base.go @@ -630,6 +630,9 @@ func (q *baseQuery) _scan( } if numRow == 0 && hasDest && isSingleRowModel(model) { + if nm, ok := model.(*structTableModel); ok && nm.isNil() { + return driver.RowsAffected(numRow), nil + } return nil, sql.ErrNoRows } return driver.RowsAffected(numRow), nil From 62501dec60f88e5af36b600ca057c013c4158eb2 Mon Sep 17 00:00:00 2001 From: CyJay Date: Fri, 15 May 2026 23:52:02 +0800 Subject: [PATCH 2/4] feat: no ErrNoRows when scan nil map --- model.go | 9 ++++++++- model_map.go | 4 ++++ query_base.go | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/model.go b/model.go index 73f3b4e94..a179350fb 100644 --- a/model.go +++ b/model.go @@ -204,10 +204,17 @@ func validMap(typ reflect.Type) error { func isSingleRowModel(m Model) bool { switch m.(type) { - case *structTableModel, + case *mapModel, + *structTableModel, *scanModel: return true default: return false } } + +// nilModel is implemented by models whose destination may be a nil pointer +// that should not be treated as "no rows" when an empty result is scanned. +type nilModel interface { + isNil() bool +} diff --git a/model_map.go b/model_map.go index b2a034c16..ffa509c83 100644 --- a/model_map.go +++ b/model_map.go @@ -39,6 +39,10 @@ func (m *mapModel) Value() any { return m.dest } +func (m *mapModel) isNil() bool { + return m.dest == nil || *m.dest == nil +} + func (m *mapModel) ScanRows(ctx context.Context, rows *sql.Rows) (int, error) { if !rows.Next() { return 0, rows.Err() diff --git a/query_base.go b/query_base.go index 7cd056c1c..963284f81 100644 --- a/query_base.go +++ b/query_base.go @@ -630,7 +630,7 @@ func (q *baseQuery) _scan( } if numRow == 0 && hasDest && isSingleRowModel(model) { - if nm, ok := model.(*structTableModel); ok && nm.isNil() { + if nm, ok := model.(nilModel); ok && nm.isNil() { return driver.RowsAffected(numRow), nil } return nil, sql.ErrNoRows From 385b0a6b19362d1bff10b4f04f389bb75caa5ccd Mon Sep 17 00:00:00 2001 From: CyJay Date: Wed, 20 May 2026 18:11:47 +0800 Subject: [PATCH 3/4] feat: no ErrNoRows when scan nil builtin type --- model_scan.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/model_scan.go b/model_scan.go index 63ad6f300..eb497b8be 100644 --- a/model_scan.go +++ b/model_scan.go @@ -28,6 +28,19 @@ func (m *scanModel) Value() any { return m.dest } +func (m *scanModel) isNil() bool { + if len(m.dest) == 0 { + return false + } + for _, item := range m.dest { + itemVal := reflect.ValueOf(item).Elem() + if isNil := itemVal.Type().Kind() == reflect.Pointer && itemVal.IsNil(); !isNil { + return false + } + } + return true +} + func (m *scanModel) ScanRows(ctx context.Context, rows *sql.Rows) (int, error) { if !rows.Next() { return 0, rows.Err() From 75e619f1b63217bac6efe546e29c02fb8a83c5a2 Mon Sep 17 00:00:00 2001 From: CyJay Date: Wed, 12 Aug 2026 11:20:05 +0800 Subject: [PATCH 4/4] fix(model): avoid treating single-value struct pointers as table models Refactor the reflect.Pointer branch in _newModel to use isSingleValueStruct so that time.Time, net.IPNet, netip.Addr, netip.Prefix and sql.Scanner implementers are routed to scanModel instead of structTableModel. Also apply the same check to the reflect.Slice branch to handle []*time.Time, []*sql.NullString and similar pointer slices correctly. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- model.go | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/model.go b/model.go index a179350fb..99e26ef94 100644 --- a/model.go +++ b/model.go @@ -5,6 +5,8 @@ import ( "database/sql" "errors" "fmt" + "net" + "net/netip" "reflect" "time" @@ -14,8 +16,12 @@ import ( var errNilModel = errors.New("bun: Model(nil)") var ( - timeType = reflect.TypeFor[time.Time]() - bytesType = reflect.TypeFor[[]byte]() + timeType = reflect.TypeFor[time.Time]() + bytesType = reflect.TypeFor[[]byte]() + scannerType = reflect.TypeFor[sql.Scanner]() + ipNetType = reflect.TypeFor[net.IPNet]() + netipAddrType = reflect.TypeFor[netip.Addr]() + netipPrefixType = reflect.TypeFor[netip.Prefix]() ) // Model is implemented by all Bun models. @@ -78,6 +84,20 @@ func newSingleModel(db *DB, dest any) (Model, error) { return _newModel(db, dest, false) } +// isSingleValueStruct +func isSingleValueStruct(typ reflect.Type) bool { + if typ.Kind() != reflect.Struct { + return false + } + if typ == timeType || + typ == ipNetType || + typ == netipAddrType || + typ == netipPrefixType { + return true + } + return reflect.PointerTo(typ).Implements(scannerType) +} + func _newModel(db *DB, dest any, scan bool) (Model, error) { switch dest := dest.(type) { case nil: @@ -125,13 +145,14 @@ func _newModel(db *DB, dest any, scan bool) (Model, error) { case reflect.Struct: return newStructTableModelValue(db, dest, v), nil case reflect.Pointer: - if v.Type().Elem().Kind() == reflect.Struct { + elemType := v.Type().Elem() + if elemType.Kind() == reflect.Struct && !isSingleValueStruct(elemType) { return newStructTableModelValue(db, dest, v), nil } case reflect.Slice: switch elemType := sliceElemType(v); elemType.Kind() { case reflect.Struct: - if elemType != timeType { + if !isSingleValueStruct(elemType) { return newSliceTableModel(db, dest, v, elemType), nil } case reflect.Map: