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
+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))
}