feat: Hooks 新增 UseTx,Before/After 改用类型化的 HookError

之前 Hooks.Before/After 返回裸 error,调用方没法区分"钩子本身失败"和"SQL
执行失败"。改成返回 *HookError(包一层 exec.HookError),配合 errors.As
能精确识别。

同时给 Hooks 加一个 UseTx(dataset) (QueryFactory, error) 方法,在 Before
之前调用,允许钩子在写操作真正执行前替换掉默认连接(比如需要自动开事务包住
写操作和写后回调的场景),返回 nil, nil 表示沿用默认连接。目前只有
InsertDataset/UpdateDataset/DeleteDataset 会调用 UseTx,SelectDataset
(只读)不需要。
This commit is contained in:
2026-08-20 17:08:29 +08:00
parent c8d1d2f219
commit 6ed2803158
7 changed files with 220 additions and 10 deletions
+12 -2
View File
@@ -238,12 +238,22 @@ func (dd *DeleteDataset) ReturnsColumns() bool {
// See Dataset#ToUpdateSQL for arguments
func (dd *DeleteDataset) Executor() (executor exec.QueryExecutor) {
if dd.hooks != nil {
dd.SetError(dd.hooks.Before(dd))
if qf, err := dd.hooks.UseTx(dd); err != nil {
dd.SetError(err)
} else if qf != nil {
dd.queryFactory = qf
}
if herr := dd.hooks.Before(dd); herr != nil {
dd.SetError(herr)
}
}
executor = dd.queryFactory.FromSQLBuilder(dd.deleteSQLBuilder())
if dd.hooks != nil {
executor.Hook(func(result interface{}) error {
return dd.hooks.After(dd, result)
if herr := dd.hooks.After(dd, result); herr != nil {
return herr
}
return nil
})
}
return executor
+21 -2
View File
@@ -1,7 +1,26 @@
package exec
import "fmt"
// HookError 是 Hooks.Before/After 失败时必须返回的类型,外部通过 db.HookError 引用。
type HookError struct {
Err error
}
func (e *HookError) Error() string {
return fmt.Sprintf("hook error: %s", e.Err)
}
func (e *HookError) Unwrap() error {
return e.Err
}
// Hooks 钩子实例
type Hooks interface {
Before(dataset interface{}) error
After(dataset interface{}, result interface{}) error
Before(dataset interface{}) *HookError
After(dataset interface{}, result interface{}) *HookError
// UseTx 在 Before 之前调用,dataset 是即将执行的 *InsertDataset/*UpdateDataset/*DeleteDataset
// (类型与 Before 收到的一致),此时数据集尚未绑定要执行的连接。返回非 nil 的 QueryFactory 时,
// 调用方会用它替换默认连接来执行这次写操作;返回 nil, nil 表示沿用默认连接。
UseTx(dataset interface{}) (QueryFactory, error)
}
+149
View File
@@ -0,0 +1,149 @@
package exec_test
import (
"errors"
"testing"
dbv2 "git.fsdpf.net/go/db"
"git.fsdpf.net/go/db/exec"
"github.com/DATA-DOG/go-sqlmock"
"github.com/stretchr/testify/suite"
)
// mockHooks 是 exec.Hooks 的最小实现,用来验证 UseTx/Before/After 与 db.HookError 的接线是否正确
type mockHooks struct {
useTxFactory exec.QueryFactory
useTxErr error
beforeErr error
afterErr error
useTxCalls int
beforeCalls int
afterCalls int
}
func (h *mockHooks) UseTx(dataset interface{}) (exec.QueryFactory, error) {
h.useTxCalls++
return h.useTxFactory, h.useTxErr
}
func (h *mockHooks) Before(dataset interface{}) *exec.HookError {
h.beforeCalls++
if h.beforeErr != nil {
return &exec.HookError{Err: h.beforeErr}
}
return nil
}
func (h *mockHooks) After(dataset interface{}, result interface{}) *exec.HookError {
h.afterCalls++
if h.afterErr != nil {
return &exec.HookError{Err: h.afterErr}
}
return nil
}
type hooksSuite struct {
suite.Suite
}
// TestBeforeErrorBefore 失败时 SQL 不会被执行,error 能用 errors.As 解出 db.HookError
func (hs *hooksSuite) TestBeforeError() {
mDB, mock, err := sqlmock.New()
hs.NoError(err)
hooks := &mockHooks{beforeErr: errors.New("before failed")}
ds := dbv2.New("mock", mDB).Insert("items").
Rows(dbv2.Record{"name": "Test1"}).
WithHook(hooks)
_, err = ds.Executor().Exec()
hs.Error(err)
var hookErr *dbv2.HookError
hs.True(errors.As(err, &hookErr))
hs.Equal("before failed", hookErr.Unwrap().Error())
hs.Equal(1, hooks.beforeCalls)
hs.Equal(0, hooks.afterCalls)
hs.NoError(mock.ExpectationsWereMet())
}
// TestAfterErrorSQL 已经执行成功,After 失败时 error 依然能用 errors.As 解出 db.HookError
// 且 result 仍然可用(区别于 SQL 本身执行失败)
func (hs *hooksSuite) TestAfterError() {
mDB, mock, err := sqlmock.New()
hs.NoError(err)
mock.ExpectExec(`INSERT INTO "items"`).WillReturnResult(sqlmock.NewResult(1, 1))
hooks := &mockHooks{afterErr: errors.New("after failed")}
ds := dbv2.New("mock", mDB).Insert("items").
Rows(dbv2.Record{"name": "Test1"}).
WithHook(hooks)
result, err := ds.Executor().Exec()
hs.Error(err)
hs.NotNil(result)
rows, rowsErr := result.RowsAffected()
hs.NoError(rowsErr)
hs.EqualValues(1, rows)
var hookErr *dbv2.HookError
hs.True(errors.As(err, &hookErr))
hs.Equal("after failed", hookErr.Unwrap().Error())
hs.NoError(mock.ExpectationsWereMet())
}
// TestUseTxSwapsConnectionUseTx 返回的 QueryFactory 会替换默认连接,SQL 应该在返回的
// factory 上执行,原始连接完全不会被用到
func (hs *hooksSuite) TestUseTxSwapsConnection() {
origDB, origMock, err := sqlmock.New()
hs.NoError(err)
txDB, txMock, err := sqlmock.New()
hs.NoError(err)
txMock.ExpectExec(`INSERT INTO "items"`).WillReturnResult(sqlmock.NewResult(1, 1))
hooks := &mockHooks{useTxFactory: exec.NewQueryFactory(txDB)}
ds := dbv2.New("mock", origDB).Insert("items").
Rows(dbv2.Record{"name": "Test1"}).
WithHook(hooks)
result, err := ds.Executor().Exec()
hs.NoError(err)
hs.NotNil(result)
hs.Equal(1, hooks.useTxCalls)
hs.NoError(txMock.ExpectationsWereMet())
hs.NoError(origMock.ExpectationsWereMet())
}
// TestUseTxNilUseTx 返回 nil factory 时沿用默认连接
func (hs *hooksSuite) TestUseTxNil() {
mDB, mock, err := sqlmock.New()
hs.NoError(err)
mock.ExpectExec(`INSERT INTO "items"`).WillReturnResult(sqlmock.NewResult(1, 1))
hooks := &mockHooks{}
ds := dbv2.New("mock", mDB).Insert("items").
Rows(dbv2.Record{"name": "Test1"}).
WithHook(hooks)
_, err = ds.Executor().Exec()
hs.NoError(err)
hs.Equal(1, hooks.useTxCalls)
hs.NoError(mock.ExpectationsWereMet())
}
func TestHooks(t *testing.T) {
suite.Run(t, new(hooksSuite))
}
+7
View File
@@ -0,0 +1,7 @@
package db
import "git.fsdpf.net/go/db/exec"
// HookError 是 Hooks.Before/After 失败时的包装错误:Before 失败时 SQL 还未执行,After 失败时
// SQL 语句本身已经执行成功、失败的是钩子本身。调用方可以用 errors.As 把它和真正的 SQL 执行失败区分开。
type HookError = exec.HookError
+12 -2
View File
@@ -306,12 +306,22 @@ func (id *InsertDataset) ReturnsColumns() bool {
// db.Insert("test").Rows(Record{"name":"Bob"}).Executor().Exec()
func (id *InsertDataset) Executor() (executor exec.QueryExecutor) {
if id.hooks != nil {
id.SetError(id.hooks.Before(id))
if qf, err := id.hooks.UseTx(id); err != nil {
id.SetError(err)
} else if qf != nil {
id.queryFactory = qf
}
if herr := id.hooks.Before(id); herr != nil {
id.SetError(herr)
}
}
executor = id.queryFactory.FromSQLBuilder(id.insertSQLBuilder())
if id.hooks != nil {
executor.Hook(func(result interface{}) error {
return id.hooks.After(id, result)
if herr := id.hooks.After(id, result); herr != nil {
return herr
}
return nil
})
}
return executor
+7 -2
View File
@@ -573,12 +573,17 @@ func (sd *SelectDataset) ToSQL() (sql string, params []interface{}, err error) {
// See Dataset#ToUpdateSQL for arguments
func (sd *SelectDataset) Executor() (executor exec.QueryExecutor) {
if sd.hooks != nil {
sd.SetError(sd.hooks.Before(sd))
if herr := sd.hooks.Before(sd); herr != nil {
sd.SetError(herr)
}
}
executor = sd.queryFactory.FromSQLBuilder(sd.selectSQLBuilder())
if sd.hooks != nil {
executor.Hook(func(result interface{}) error {
return sd.hooks.After(sd, result)
if herr := sd.hooks.After(sd, result); herr != nil {
return herr
}
return nil
})
}
return executor
+12 -2
View File
@@ -236,12 +236,22 @@ func (ud *UpdateDataset) ReturnsColumns() bool {
// db.Update("test").Set(Record{"name":"Bob", update: time.Now()}).Executor()
func (ud *UpdateDataset) Executor() (executor exec.QueryExecutor) {
if ud.hooks != nil {
ud.SetError(ud.hooks.Before(ud))
if qf, err := ud.hooks.UseTx(ud); err != nil {
ud.SetError(err)
} else if qf != nil {
ud.queryFactory = qf
}
if herr := ud.hooks.Before(ud); herr != nil {
ud.SetError(herr)
}
}
executor = ud.queryFactory.FromSQLBuilder(ud.updateSQLBuilder())
if ud.hooks != nil {
executor.Hook(func(result interface{}) error {
return ud.hooks.After(ud, result)
if herr := ud.hooks.After(ud, result); herr != nil {
return herr
}
return nil
})
}
return executor