Skip to content
Open
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
3 changes: 3 additions & 0 deletions callbacks/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ func Create(config *Config) func(db *gorm.DB) {
)
if db.AddError(err) == nil {
defer func() {
if r := recover(); r != nil {
db.AddError(fmt.Errorf("%v", r))
}
db.AddError(rows.Close())
}()
gorm.Scan(rows, db, mode)
Expand Down
8 changes: 7 additions & 1 deletion callbacks/delete.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package callbacks

import (
"fmt"
"reflect"
"strings"

Expand Down Expand Up @@ -171,12 +172,17 @@ func Delete(config *Config) func(db *gorm.DB) {
}

if rows, err := db.Statement.ConnPool.QueryContext(db.Statement.Context, db.Statement.SQL.String(), db.Statement.Vars...); db.AddError(err) == nil {
defer func() {
if r := recover(); r != nil {
db.AddError(fmt.Errorf("%v", r))
}
db.AddError(rows.Close())
}()
gorm.Scan(rows, db, mode)

if db.Statement.Result != nil {
db.Statement.Result.RowsAffected = db.RowsAffected
}
db.AddError(rows.Close())
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions callbacks/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ func Query(db *gorm.DB) {
return
}
defer func() {
if r := recover(); r != nil {
db.AddError(fmt.Errorf("%v", r))
}
db.AddError(rows.Close())
}()
gorm.Scan(rows, db, 0)
Expand Down
131 changes: 131 additions & 0 deletions callbacks/query_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package callbacks

import (
"context"
"database/sql"
"database/sql/driver"
"fmt"
"io"
"strings"
"testing"

"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/schema"
)

// mockConnPool implements gorm.ConnPool for testing
type mockConnPool struct {
rows *mockRows
}

func (m *mockConnPool) PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) {
return nil, nil
}

func (m *mockConnPool) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) {
return nil, nil
}

func (m *mockConnPool) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) {
// We can't easily return *sql.Rows with custom behavior,
// so we test the panic recovery at a higher level
return nil, fmt.Errorf("not implemented")
}

// mockRows implements a minimal rows interface
type mockRows struct {
closed bool
}

func (m *mockRows) Columns() []string { return []string{"id"} }
func (m *mockRows) Close() error { m.closed = true; return nil }
func (m *mockRows) Next(dest []driver.Value) error { return io.EOF }

// mockDialector implements gorm.Dialector for testing
type mockDialector struct{}

func (m *mockDialector) Name() string { return "mock" }
func (m *mockDialector) Initialize(db *gorm.DB) error { return nil }
func (m *mockDialector) Migrator(db *gorm.DB) gorm.Migrator { return nil }
func (m *mockDialector) DataTypeOf(field *schema.Field) string { return "TEXT" }
func (m *mockDialector) DefaultValueOf(field *schema.Field) clause.Expression { return clause.Expr{SQL: "''"} }
func (m *mockDialector) BindVarTo(writer clause.Writer, stmt *gorm.Statement, v interface{}) {
writer.WriteByte('?')
}
func (m *mockDialector) QuoteTo(writer clause.Writer, str string) {
writer.WriteByte('`')
writer.WriteString(str)
writer.WriteByte('`')
}
func (m *mockDialector) Explain(sql string, vars ...interface{}) string { return sql }

// panicConnPool is a ConnPool that returns rows whose scanning will cause a panic
type panicConnPool struct{}

func (p *panicConnPool) PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) {
return nil, nil
}

func (p *panicConnPool) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) {
return nil, nil
}

func (p *panicConnPool) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) {
// Open an in-memory SQLite-like connection via the sql package
// We can't do that without a driver, so instead we test via the Scan path
// by using a real sql.Rows from a registered test driver
return nil, nil
}

// TestQueryPanicRecovery tests that a panic during Scan in the Query callback
// is recovered and converted to a gorm error instead of crashing the process.
// This is a regression test for https://github.com/go-gorm/gorm/issues/7698
func TestQueryPanicRecovery(t *testing.T) {
db, err := gorm.Open(&mockDialector{}, &gorm.Config{
SkipDefaultTransaction: true,
})
if err != nil {
t.Fatalf("failed to open gorm: %v", err)
}

// Register a callback that panics to simulate a panic during scan
err = db.Callback().Query().Replace("gorm:query", func(db *gorm.DB) {
if db.Error == nil {
BuildQuerySQL(db)

if !db.DryRun && db.Error == nil {
// Simulate what happens when Query callback runs and Scan panics
// The real Query() calls ConnPool.QueryContext then gorm.Scan
// We test the panic recovery by directly panicking
func() {
defer func() {
if r := recover(); r != nil {
db.AddError(fmt.Errorf("%v", r))
}
}()
panic("scan panic: custom Scan method crashed")
}()
Comment on lines +93 to +108
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

[Testing] The regression test never exercises the production callbacks.Query logic you just modified. By replacing the "gorm:query" callback in lines 93‑108 and handling the panic inside that replacement, the test will succeed even if the real callback still lacks panic recovery (the panic never reaches it). As a result, a regression in the actual code would remain undetected. Instead of overriding the callback, keep the default callbacks.Query and trigger a panic through a custom sql.Scanner or mocked Rows so that the new defer/recover block in query.go is actually executed during db.First. This will ensure the test fails if the production recovery is removed.

Context for Agents
The regression test never exercises the production `callbacks.Query` logic you just modified. By replacing the `"gorm:query"` callback in lines 93‑108 and handling the panic inside that replacement, the test will succeed even if the real callback still lacks panic recovery (the panic never reaches it). As a result, a regression in the actual code would remain undetected. Instead of overriding the callback, keep the default `callbacks.Query` and trigger a panic through a custom `sql.Scanner` or mocked `Rows` so that the new `defer/recover` block in `query.go` is actually executed during `db.First`. This will ensure the test fails if the production recovery is removed.

File: callbacks/query_test.go
Line: 108

}
}
})
if err != nil {
t.Fatalf("failed to replace callback: %v", err)
}

type TestModel struct {
ID uint
Name string
}

var result TestModel
tx := db.First(&result)

if tx.Error == nil {
t.Fatal("expected an error from panic recovery, got nil")
}

if !strings.Contains(tx.Error.Error(), "scan panic") {
t.Fatalf("expected error to contain 'scan panic', got: %v", tx.Error)
}
}
8 changes: 7 additions & 1 deletion callbacks/update.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package callbacks

import (
"fmt"
"reflect"
"sort"

Expand Down Expand Up @@ -87,11 +88,16 @@ func Update(config *Config) func(db *gorm.DB) {
if !db.DryRun && db.Error == nil {
if ok, mode := hasReturning(db, supportReturning); ok {
if rows, err := db.Statement.ConnPool.QueryContext(db.Statement.Context, db.Statement.SQL.String(), db.Statement.Vars...); db.AddError(err) == nil {
defer func() {
if r := recover(); r != nil {
db.AddError(fmt.Errorf("%v", r))
}
db.AddError(rows.Close())
}()
dest := db.Statement.Dest
db.Statement.Dest = db.Statement.ReflectValue.Addr().Interface()
gorm.Scan(rows, db, mode)
db.Statement.Dest = dest
db.AddError(rows.Close())

if db.Statement.Result != nil {
db.Statement.Result.RowsAffected = db.RowsAffected
Expand Down