package resx_test import ( "database/sql" "errors" "fmt" "testing" "time" "git.fsdpf.net/go/contracts/base" "git.fsdpf.net/go/db" "git.fsdpf.net/go/db/engine" "git.fsdpf.net/go/reflux" "git.fsdpf.net/go/req" "git.fsdpf.net/go/req/resx" "github.com/DATA-DOG/go-sqlmock" "github.com/samber/do/v2" "github.com/stretchr/testify/suite" _ "git.fsdpf.net/go/db/dialect/mysql" _ "git.fsdpf.net/go/db/dialect/sqlite3" _ "git.fsdpf.net/go/db/schema/dialect/sqlite3" ) // hooksTest 用 sqlmock 验证 dataProcessor 的钩子编排:原来的 RolesConditionFunc(权限过滤)、 // NeedsChangeCaptureFunc(是否需要抓取)、AfterChangeFunc(变更内容)、OnWriteFunc(无条件触发) // 四块能力现在是 DataInterceptor(权限过滤 + 是否需要抓取)+ ResChangeEventFunc(无条件触发)+ // ResChangeRowFunc(变更内容,仅需要抓取时调用),这里按原来的四块能力分别验证。 type hooksTest struct { suite.Suite } func TestResourceHooks(t *testing.T) { suite.Run(t, new(hooksTest)) } // newResource 构造一个绑定了 sqlmock 的 User 资源,interceptor/event 为 nil 表示不注册对应钩子。 func (t *hooksTest) newResource( interceptor resx.DataInterceptor, event resx.ResChangeEventFunc, ) (req.Resource, sqlmock.Sqlmock) { mockDB, mock, err := sqlmock.New() t.Require().NoError(err) app := do.New() do.Provide(app, func(i do.Injector) (engine.Engine, error) { return engine.Mock(map[string]engine.MockDBConfig{ "default": {Driver: "mysql", Mock: mockDB}, }), nil }) if interceptor != nil { do.Provide(app, func(i do.Injector) (resx.DataInterceptor, error) { return interceptor, nil }) } if event != nil { do.Provide(app, func(i do.Injector) (resx.ResChangeEventFunc, error) { return event, nil }) } res := resx.New(app, "User", "users", resx.WithUuid("4bf3a311-cbe1-4236-bdda-c64ab04ae9b1"), resx.WithConn("default"), resx.WithFields( resx.NewResField("name", "User", resx.FieldWithName("姓名"), resx.FieldWithDataType(req.ResString)), ), ) return res, mock } // -------- 原 RolesConditionFunc:权限过滤 -------- // TestOnBefore_RolesCondition_Cond 权限条件直接拼进 WHERE func (t *hooksTest) TestOnBefore_RolesCondition_Cond() { res, mock := t.newResource( func(res req.Resource, u req.User, category resx.ResEventType) (*db.SelectDataset, db.Expression, resx.ResChangeRowFunc) { t.Equal(resx.ResEventSelect, category, "SELECT 场景的 category 是 ResEventSelect") return nil, db.C("owned_user").Eq(u.Uuid()), nil }, nil, ) // 实际生成的 SQL: // SELECT * FROM `users` AS `User` WHERE (`owned_user` = '00000000-0000-0000-0000-000000000000') sql, _, _ := res.GetDBTable(base.GetAnonymous(), req.WithPermission(req.ResAll)).Select(db.Star()).Executor().ToSQL() t.Contains(sql, "owned_user") t.Contains(sql, "00000000-0000-0000-0000-000000000000") mock.ExpectQuery("SELECT"). WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(1, "张三")) rows, err := res.GetDBTable(base.GetAnonymous(), req.WithPermission(req.ResAll)).Select(db.Star()).Executor().GetRecords() t.NoError(err) t.Len(rows, 1) t.NoError(mock.ExpectationsWereMet()) } // TestOnBefore_RolesCondition_Sub 权限条件以子查询的形式重写 FROM func (t *hooksTest) TestOnBefore_RolesCondition_Sub() { res, mock := t.newResource( func(res req.Resource, u req.User, category resx.ResEventType) (*db.SelectDataset, db.Expression, resx.ResChangeRowFunc) { sub := res.DB().From(res.GetTableExpr().As(res.GetCode())). Select(db.Star()). Where(db.C("owned_user").Eq(u.Uuid())) return sub, nil, nil }, nil, ) // 实际生成的 SQL: // SELECT * FROM (SELECT * FROM `users` AS `User` WHERE (`owned_user` = '00000000-0000-0000-0000-000000000000')) AS `User` sql, _, _ := res.GetDBTable(base.GetAnonymous(), req.WithPermission(req.ResAll)).Select(db.Star()).Executor().ToSQL() t.Contains(sql, "owned_user") mock.ExpectQuery("SELECT"). WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(2, "李四")) rows, err := res.GetDBTable(base.GetAnonymous(), req.WithPermission(req.ResAll)).Select(db.Star()).Executor().GetRecords() t.NoError(err) t.Len(rows, 1) t.NoError(mock.ExpectationsWereMet()) } // -------- 原 NeedsChangeCaptureFunc:是否需要抓取快照/变更内容 -------- // TestOnBefore_NeedsCapture_True 判定需要抓取时,UPDATE 前会多一次快照查询,ResChangeRowFunc 拿到完整的变更行; // onChange 非 nil 时写操作还会被自动包一层事务(见 UseTx) func (t *hooksTest) TestOnBefore_NeedsCapture_True() { var gotRows []resx.ResChangeRow res, mock := t.newResource( func(res req.Resource, u req.User, category resx.ResEventType) (*db.SelectDataset, db.Expression, resx.ResChangeRowFunc) { if category != resx.ResEventUpdate { return nil, nil, nil } return nil, nil, func(rows []resx.ResChangeRow) error { gotRows = rows return nil } }, nil, ) // onChange 非 nil,写操作会被自动包一层事务 mock.ExpectBegin() // 抓取快照的前置 SELECT,实际生成的 SQL: // SELECT * FROM `users` AS `User` WHERE (`id` = 1) mock.ExpectQuery("SELECT"). WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(1, "旧名字")) // 实际 UPDATE,实际生成的 SQL: // UPDATE `users` AS `User` SET `name`='新名字' WHERE (`id` = 1) mock.ExpectExec("UPDATE").WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() _, err := res.GetDBTable(base.GetAnonymous(), req.WithPermission(req.ResAll)).Update(). Set(db.Record{"name": "新名字"}). Where(db.C("id").Eq(1)). Executor().Exec() t.NoError(err) t.NoError(mock.ExpectationsWereMet()) t.Require().Len(gotRows, 1) t.Equal(resx.ResEventUpdate, gotRows[0].Type()) t.EqualValues(1, gotRows[0].ID()) t.Equal("旧名字", gotRows[0].Old().Get("name").String()) t.Equal("新名字", gotRows[0].New().Get("name").String()) } // TestOnBefore_NeedsCapture_False 判定不需要抓取时,不会有额外的快照查询,也不会自动开事务; // ResChangeEventFunc 依然无条件调用,但 ResChangeRowFunc 完全不会被调用 func (t *hooksTest) TestOnBefore_NeedsCapture_False() { var eventCalled bool res, mock := t.newResource( func(res req.Resource, u req.User, category resx.ResEventType) (*db.SelectDataset, db.Expression, resx.ResChangeRowFunc) { return nil, nil, nil }, func(event resx.ResChangeEvent) error { eventCalled = true return nil }, ) // 没有安排快照查询、没有 Begin:如果 Before 阶段多发起一次 SELECT 或事务,这里会因为顺序对不上而报错。 // 实际生成的 SQL:UPDATE `users` AS `User` SET `name`='新名字' WHERE (`id` = 1) mock.ExpectExec("UPDATE").WillReturnResult(sqlmock.NewResult(0, 1)) _, err := res.GetDBTable(base.GetAnonymous(), req.WithPermission(req.ResAll)).Update(). Set(db.Record{"name": "新名字"}). Where(db.C("id").Eq(1)). Executor().Exec() t.NoError(err) t.NoError(mock.ExpectationsWereMet()) t.True(eventCalled, "ResChangeEventFunc 不受 needsCapture 影响,应该无条件调用") } // -------- 原 AfterChangeFunc:变更内容 -------- // TestOnAfter_RowsContent_Insert 验证 ResChangeRowFunc 收到的 INSERT 变更行内容:Type/Res/ID/Old/New/Tx func (t *hooksTest) TestOnAfter_RowsContent_Insert() { var gotRows []resx.ResChangeRow res, mock := t.newResource( func(res req.Resource, u req.User, category resx.ResEventType) (*db.SelectDataset, db.Expression, resx.ResChangeRowFunc) { if category != resx.ResEventInsert { return nil, nil, nil } return nil, nil, func(rows []resx.ResChangeRow) error { gotRows = rows return nil } }, nil, ) // onChange 非 nil,写操作会被自动包一层事务 mock.ExpectBegin() // 实际生成的 SQL: // INSERT INTO `users` (`created_user`, `name`, `owned_user`) VALUES ('00000000-0000-0000-0000-000000000000', '张三', '00000000-0000-0000-0000-000000000000') mock.ExpectExec("INSERT").WillReturnResult(sqlmock.NewResult(10, 1)) mock.ExpectCommit() _, err := res.GetDBTable(base.GetAnonymous(), req.WithPermission(req.ResAll)).Insert(). Rows(db.Record{"name": "张三"}). Executor().Exec() t.NoError(err) t.NoError(mock.ExpectationsWereMet()) t.Require().Len(gotRows, 1) row := gotRows[0] t.Equal(resx.ResEventInsert, row.Type()) t.Equal("User", row.Res().GetCode()) t.EqualValues(10, row.ID()) t.Equal(reflux.Nil, row.Old(), "INSERT 没有变更前快照") t.Equal("张三", row.New().Get("name").String()) t.NotNil(row.Tx(), "onChange 非 nil 时会自动开启事务,Tx() 应该能拿到它") } // -------- 原 OnWriteFunc:无条件触发 -------- // TestOnAfter_UnconditionalOnWrite ResChangeEventFunc 不受 needsCapture 门槛限制,每次写操作成功后都会调用 func (t *hooksTest) TestOnAfter_UnconditionalOnWrite() { var afterCalled bool var gotResult sql.Result res, mock := t.newResource( func(res req.Resource, u req.User, category resx.ResEventType) (*db.SelectDataset, db.Expression, resx.ResChangeRowFunc) { return nil, nil, nil // 不需要留痕 }, func(event resx.ResChangeEvent) error { afterCalled = true gotResult = event.Result() return nil }, ) // 实际生成的 SQL:DELETE FROM `users` AS `User` WHERE (`id` = 1) mock.ExpectExec("DELETE").WillReturnResult(sqlmock.NewResult(0, 3)) _, err := res.GetDBTable(base.GetAnonymous(), req.WithPermission(req.ResAll)).Delete(). Where(db.C("id").Eq(1)). Executor().Exec() t.NoError(err) t.NoError(mock.ExpectationsWereMet()) t.True(afterCalled, "ResChangeEventFunc 应该无条件触发,不受 needsCapture 门槛限制") affected, _ := gotResult.RowsAffected() t.EqualValues(3, affected) } // -------- INSERT ... SELECT:写入前拿不到具体行数据,写入后按 id 反查补全 -------- // TestInsertFromQuery_FetchesInsertedRowsAfterward beforeInsertFromQuery 是空实现, // 真正的内容在 After 阶段:rh.rows 为空触发 fetchInsertedRows,按写入后拿到的自增 id 反查补全。 // fetchInsertedRows 走的是独立的 GetDBTable 连接,不在自动开启的事务里,所以顺序是 // Begin -> INSERT -> 反查 SELECT -> Commit。 func (t *hooksTest) TestInsertFromQuery_FetchesInsertedRowsAfterward() { var gotRows []resx.ResChangeRow res, mock := t.newResource( func(res req.Resource, u req.User, category resx.ResEventType) (*db.SelectDataset, db.Expression, resx.ResChangeRowFunc) { if category != resx.ResEventInsert { return nil, nil, nil } return nil, nil, func(rows []resx.ResChangeRow) error { gotRows = rows return nil } }, nil, ) sub := res.DB().From(db.T("legacy_users")).Select(db.Star()) mock.ExpectBegin() // 实际生成的 SQL:INSERT INTO `users` SELECT * FROM `legacy_users` mock.ExpectExec("INSERT").WillReturnResult(sqlmock.NewResult(10, 2)) // finalizeInsertedRows 反查补全,实际生成的 SQL: // SELECT * FROM `users` AS `User` WHERE (`id` IN (10, 11)) mock.ExpectQuery("SELECT").WillReturnRows( sqlmock.NewRows([]string{"id", "name"}).AddRow(10, "张三").AddRow(11, "李四"), ) mock.ExpectCommit() _, err := res.GetDBTable(base.GetAnonymous(), req.WithPermission(req.ResAll)).Insert().FromQuery(sub).Executor().Exec() t.NoError(err) t.NoError(mock.ExpectationsWereMet()) t.Require().Len(gotRows, 2) ids := []int64{gotRows[0].ID(), gotRows[1].ID()} t.ElementsMatch([]int64{10, 11}, ids) names := []string{gotRows[0].New().Get("name").String(), gotRows[1].New().Get("name").String()} t.ElementsMatch([]string{"张三", "李四"}, names) } // -------- 批量 INSERT 的自增 id 分配 -------- // TestBatchInsertIds_Mysql mysql: lastId 是第一条,后续行 id 依次递增 func (t *hooksTest) TestBatchInsertIds_Mysql() { var gotRows []resx.ResChangeRow res, mock := t.newResource( func(res req.Resource, u req.User, category resx.ResEventType) (*db.SelectDataset, db.Expression, resx.ResChangeRowFunc) { return nil, nil, func(rows []resx.ResChangeRow) error { gotRows = rows return nil } }, nil, ) mock.ExpectBegin() // 实际生成的 SQL: // INSERT INTO `users` (`created_user`, `name`, `owned_user`) VALUES // ('00000000-0000-0000-0000-000000000000', 'A', '00000000-0000-0000-0000-000000000000'), // ('00000000-0000-0000-0000-000000000000', 'B', '00000000-0000-0000-0000-000000000000'), // ('00000000-0000-0000-0000-000000000000', 'C', '00000000-0000-0000-0000-000000000000') mock.ExpectExec("INSERT").WillReturnResult(sqlmock.NewResult(100, 3)) mock.ExpectCommit() _, err := res.GetDBTable(base.GetAnonymous(), req.WithPermission(req.ResAll)).Insert().Rows( db.Record{"name": "A"}, db.Record{"name": "B"}, db.Record{"name": "C"}, ).Executor().Exec() t.NoError(err) t.NoError(mock.ExpectationsWereMet()) t.Require().Len(gotRows, 3) t.EqualValues(100, gotRows[0].ID()) t.EqualValues(101, gotRows[1].ID()) t.EqualValues(102, gotRows[2].ID()) } // TestBatchInsertIds_LocalDB sqlite3/duckdb 等本地库:lastId 是最后一条,需要倒推前面几条的 id。 // 这里用真实的内存 sqlite3 而不是 sqlmock:DB() 现在会自动建表(见 resx.go 的 // autoCreateTable),sqlmock 没法真的执行 CREATE TABLE/INSERT,所以改用真实 SQLite 让建表和 // 插入都按真实语义跑一遍。连接名用独立的(不用 "default"),避免和其它用例共享 engine 包内部 // 的全局连接缓存。onChange 非 nil 会自动 Begin/Commit 一个真实事务,无需额外 mock。 func (t *hooksTest) TestBatchInsertIds_LocalDB() { // engine 的连接缓存是包级单例,按名字缓存 *sql.DB;连接名必须每次唯一, // 否则 -count=N 重跑时会复用上一轮建好表、插过数据的同一个内存库,自增 id 对不上 connName := fmt.Sprintf("test_batch_insert_local_db_%d", time.Now().UnixNano()) app := do.New() do.Provide(app, func(i do.Injector) (engine.Engine, error) { return engine.Open(map[string]engine.DBConfig{ connName: engine.NewDBConfig("sqlite3", engine.WithSQLiteFile(":memory:")), }), nil }) var gotRows []resx.ResChangeRow do.Provide(app, func(i do.Injector) (resx.DataInterceptor, error) { return func(res req.Resource, u req.User, category resx.ResEventType) (*db.SelectDataset, db.Expression, resx.ResChangeRowFunc) { return nil, nil, func(rows []resx.ResChangeRow) error { gotRows = rows return nil } }, nil }) // id/created_user/owned_user 是 autoCreateTable 建表、以及 applyDefaultValue 无条件填充 // 用到的系统字段,真实建表场景下必须显式声明,否则真实 INSERT 会报"列不存在" res := resx.New(app, "User", "users", resx.WithConn(connName), resx.WithFields( resx.NewResField("id", "User", resx.FieldWithName("ID"), resx.FieldWithDataType(req.ResInteger)), resx.NewResField("name", "User", resx.FieldWithName("姓名"), resx.FieldWithDataType(req.ResString)), resx.NewResField("created_user", "User", resx.FieldWithName("创建者"), resx.FieldWithDataType(req.ResString)), resx.NewResField("owned_user", "User", resx.FieldWithName("拥有者"), resx.FieldWithDataType(req.ResString)), ), ) _, err := res.GetDBTable(base.GetAnonymous(), req.WithPermission(req.ResAll)).Insert().Rows( db.Record{"name": "A"}, db.Record{"name": "B"}, db.Record{"name": "C"}, ).Executor().Exec() t.Require().NoError(err) // 真实自增:一条 INSERT 里的 lastId 是最后一条(3),insertRowIds 按 isLocalDB 分支倒推出 1/2/3 t.Require().Len(gotRows, 3) t.EqualValues(1, gotRows[0].ID()) t.EqualValues(2, gotRows[1].ID()) t.EqualValues(3, gotRows[2].ID()) } // -------- UPDATE/DELETE 权限过滤的子查询改写 -------- // TestUpdate_RolesConditionSub 权限条件以子查询形式重写 WHERE:User.id IN (子查询) func (t *hooksTest) TestUpdate_RolesConditionSub() { res, _ := t.newResource( func(res req.Resource, u req.User, category resx.ResEventType) (*db.SelectDataset, db.Expression, resx.ResChangeRowFunc) { sub := res.DB().From(res.GetTableExpr().As(res.GetCode())). Select(db.Star()). Where(db.C("owned_user").Eq(u.Uuid())) return sub, nil, nil }, nil, ) // 实际生成的 SQL: // UPDATE `users` AS `User` SET `name`='新名字' WHERE ((`id` = 1) AND (`id` IN ((SELECT `User`.`id` FROM `users` AS `User` WHERE (`owned_user` = '00000000-0000-0000-0000-000000000000'))))) sql, _, _ := res.GetDBTable(base.GetAnonymous(), req.WithPermission(req.ResAll)).Update(). Set(db.Record{"name": "新名字"}). Where(db.C("id").Eq(1)). Executor().ToSQL() t.Contains(sql, "`id` IN ((SELECT") t.Contains(sql, "owned_user") // 原有的 WHERE 条件依然保留 t.Contains(sql, "`id` = 1") } // TestDelete_RolesConditionSub DELETE 场景下同样会被改写成子查询形式 func (t *hooksTest) TestDelete_RolesConditionSub() { res, _ := t.newResource( func(res req.Resource, u req.User, category resx.ResEventType) (*db.SelectDataset, db.Expression, resx.ResChangeRowFunc) { sub := res.DB().From(res.GetTableExpr().As(res.GetCode())). Select(db.Star()). Where(db.C("owned_user").Eq(u.Uuid())) return sub, nil, nil }, nil, ) // 实际生成的 SQL: // DELETE FROM `users` AS `User` WHERE ((`id` = 1) AND (`id` IN ((SELECT `User`.`id` FROM `users` AS `User` WHERE (`owned_user` = '00000000-0000-0000-0000-000000000000'))))) sql, _, _ := res.GetDBTable(base.GetAnonymous(), req.WithPermission(req.ResAll)).Delete(). Where(db.C("id").Eq(1)). Executor().ToSQL() t.Contains(sql, "`id` IN ((SELECT") t.Contains(sql, "owned_user") t.Contains(sql, "`id` = 1") } // -------- DELETE 的快照捕获 -------- // TestDelete_CapturesSnapshot 判定需要抓取时,DELETE 前会先查一次快照,ResChangeRowFunc 能拿到被删行的 Old() func (t *hooksTest) TestDelete_CapturesSnapshot() { var gotRows []resx.ResChangeRow res, mock := t.newResource( func(res req.Resource, u req.User, category resx.ResEventType) (*db.SelectDataset, db.Expression, resx.ResChangeRowFunc) { if category != resx.ResEventDelete { return nil, nil, nil } return nil, nil, func(rows []resx.ResChangeRow) error { gotRows = rows return nil } }, nil, ) mock.ExpectBegin() // 抓取快照的前置 SELECT,实际生成的 SQL: // SELECT * FROM `users` AS `User` WHERE (`id` = 5) mock.ExpectQuery("SELECT"). WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(5, "旧值")) // 实际 DELETE,实际生成的 SQL:DELETE FROM `users` AS `User` WHERE (`id` = 5) mock.ExpectExec("DELETE").WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() _, err := res.GetDBTable(base.GetAnonymous(), req.WithPermission(req.ResAll)).Delete(). Where(db.C("id").Eq(5)). Executor().Exec() t.NoError(err) t.NoError(mock.ExpectationsWereMet()) t.Require().Len(gotRows, 1) t.Equal(resx.ResEventDelete, gotRows[0].Type()) t.EqualValues(5, gotRows[0].ID()) t.Equal("旧值", gotRows[0].Old().Get("name").String()) } // -------- Cols/Vals 插入路径 -------- // TestInsertColsVals_Capture Cols/Vals 路径同样支持 capture,能拿到本次写入的字段值 func (t *hooksTest) TestInsertColsVals_Capture() { var gotRows []resx.ResChangeRow res, mock := t.newResource( func(res req.Resource, u req.User, category resx.ResEventType) (*db.SelectDataset, db.Expression, resx.ResChangeRowFunc) { if category != resx.ResEventInsert { return nil, nil, nil } return nil, nil, func(rows []resx.ResChangeRow) error { gotRows = rows return nil } }, nil, ) mock.ExpectBegin() // 实际生成的 SQL: // INSERT INTO `users` (`name`, `owned_user`, `created_user`) VALUES ('赵六', '00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000000') mock.ExpectExec("INSERT").WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectCommit() _, err := res.GetDBTable(base.GetAnonymous(), req.WithPermission(req.ResAll)).Insert().Cols("name").Vals( db.Vals{"赵六"}, ).Executor().Exec() t.NoError(err) t.NoError(mock.ExpectationsWereMet()) t.Require().Len(gotRows, 1) t.Equal("赵六", gotRows[0].New().Get("name").String()) } // TestInsertColsVals_DoesNotStripSystemColumns Cols/Vals 路径不会调用 normalizeSaveValue, // 调用方显式把 created_user 放进 Cols 时不会像 Rows() 路径那样被剔除/改写——这是两条路径 // 目前存在的行为差异,用测试把它钉住,避免以后被误当成两条路径完全等价。 func (t *hooksTest) TestInsertColsVals_DoesNotStripSystemColumns() { res, _ := t.newResource(nil, nil) // 实际生成的 SQL(created_user 没有被剔除/改写): // INSERT INTO `users` (`name`, `created_user`, `owned_user`) VALUES ('赵六', 'attacker-uuid', '00000000-0000-0000-0000-000000000000') sql, _, _ := res.GetDBTable(base.GetAnonymous(), req.WithPermission(req.ResAll)).Insert().Cols("name", "created_user").Vals( db.Vals{"赵六", "attacker-uuid"}, ).Executor().ToSQL() t.Contains(sql, "attacker-uuid") } // -------- 虚拟资源短路 -------- // TestVirtualResource_SkipsRolesConditionAndOnAfter 虚拟资源的 UPDATE/DELETE 不做权限过滤, // 写操作完成后也不会触发 ResChangeEventFunc/ResChangeRowFunc,也不会自动开事务 func (t *hooksTest) TestVirtualResource_SkipsRolesConditionAndOnAfter() { var eventCalled, changeCalled bool mockDB, mock, err := sqlmock.New() t.Require().NoError(err) app := do.New() do.Provide(app, func(i do.Injector) (engine.Engine, error) { return engine.Mock(map[string]engine.MockDBConfig{ "default": {Driver: "mysql", Mock: mockDB}, }), nil }) do.Provide(app, func(i do.Injector) (resx.DataInterceptor, error) { return func(res req.Resource, u req.User, category resx.ResEventType) (*db.SelectDataset, db.Expression, resx.ResChangeRowFunc) { return nil, db.C("owned_user").Eq(u.Uuid()), func(rows []resx.ResChangeRow) error { changeCalled = true return nil } }, nil }) do.Provide(app, func(i do.Injector) (resx.ResChangeEventFunc, error) { return func(event resx.ResChangeEvent) error { eventCalled = true return nil }, nil }) baseRes := resx.New(app, "User", "users", resx.WithConn("default")) res := resx.NewVirtualResource(baseRes, "User", db.From(db.T("users")), resx.WithFields(resx.NewResField("name", "User", resx.FieldWithName("姓名"), resx.FieldWithDataType(req.ResString))), ) // 实际生成的 SQL(虚拟资源拿子查询拼表名,且没有 owned_user 权限条件): // UPDATE (SELECT * FROM `users`) AS `User` SET `name`='新名字' WHERE (`id` = 1) sql, _, _ := res.GetDBTable(base.GetAnonymous(), req.WithPermission(req.ResAll)).Update(). Set(db.Record{"name": "新名字"}). Where(db.C("id").Eq(1)). Executor().ToSQL() t.NotContains(sql, "owned_user", "虚拟资源不应该走权限过滤改写") // 没有安排 Begin:虚拟资源在 UseTx 里直接短路,不会自动开事务 mock.ExpectExec("UPDATE").WillReturnResult(sqlmock.NewResult(0, 1)) _, err = res.GetDBTable(base.GetAnonymous(), req.WithPermission(req.ResAll)).Update(). Set(db.Record{"name": "新名字"}). Where(db.C("id").Eq(1)). Executor().Exec() t.NoError(err) t.NoError(mock.ExpectationsWereMet()) t.False(eventCalled, "虚拟资源写操作后不应该触发 ResChangeEventFunc") t.False(changeCalled, "虚拟资源写操作后不应该触发 ResChangeRowFunc") } // -------- 变更内容里字面量表达式的还原 -------- // TestBuildChanges_ResolvesNonPlaceholderLiteral resolveLiteralValue 对形如 db.L("NOW()") 这种 // 没有占位符的字面量表达式,应该还原成它的原始文本,而不是占位符对应的参数 func (t *hooksTest) TestBuildChanges_ResolvesNonPlaceholderLiteral() { var gotRows []resx.ResChangeRow res, mock := t.newResource( func(res req.Resource, u req.User, category resx.ResEventType) (*db.SelectDataset, db.Expression, resx.ResChangeRowFunc) { if category != resx.ResEventUpdate { return nil, nil, nil } return nil, nil, func(rows []resx.ResChangeRow) error { gotRows = rows return nil } }, nil, ) mock.ExpectBegin() // 抓取快照的前置 SELECT,实际生成的 SQL: // SELECT * FROM `users` AS `User` WHERE (`id` = 1) mock.ExpectQuery("SELECT"). WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(1, "旧名字")) // 实际 UPDATE,注意 NOW() 是原样拼进 SQL 的字面量,不是占位符参数: // UPDATE `users` AS `User` SET `name`=NOW() WHERE (`id` = 1) mock.ExpectExec("UPDATE").WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() _, err := res.GetDBTable(base.GetAnonymous(), req.WithPermission(req.ResAll)).Update(). Set(db.Record{"name": db.L("NOW()")}). Where(db.C("id").Eq(1)). Executor().Exec() t.NoError(err) t.NoError(mock.ExpectationsWereMet()) t.Require().Len(gotRows, 1) t.Equal("NOW()", gotRows[0].New().Get("name").String()) } // -------- 事务托底:UseTx 自动开事务 + Commit/Rollback -------- // TestUseTx_RollsBackOnChangeError onChange 失败时,自动开启的事务要整体回滚,连带这次写操作 // 本身一起撤销——而不是"数据写成功了、只是留痕/watcher 失败" func (t *hooksTest) TestUseTx_RollsBackOnChangeError() { onChangeErr := errors.New("write change log failed") res, mock := t.newResource( func(res req.Resource, u req.User, category resx.ResEventType) (*db.SelectDataset, db.Expression, resx.ResChangeRowFunc) { return nil, nil, func(rows []resx.ResChangeRow) error { return onChangeErr } }, nil, ) mock.ExpectBegin() mock.ExpectExec("INSERT").WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectRollback() _, err := res.GetDBTable(base.GetAnonymous(), req.WithPermission(req.ResAll)).Insert(). Rows(db.Record{"name": "赵六"}). Executor().Exec() t.Error(err) var hookErr *db.HookError t.True(errors.As(err, &hookErr), "error 应该能用 errors.As 解出 db.HookError") t.ErrorIs(hookErr.Unwrap(), onChangeErr) t.NoError(mock.ExpectationsWereMet(), "应该是 Begin -> INSERT -> Rollback,而不是 Commit") } // TestUseTx_ReusesCallerProvidedTx 调用方已经显式传入事务时,UseTx 不会再自动开一个新的, // After 也不会自动 Commit/Rollback 它——事务的生命周期仍然由调用方自己负责 func (t *hooksTest) TestUseTx_ReusesCallerProvidedTx() { var gotRows []resx.ResChangeRow res, mock := t.newResource( func(res req.Resource, u req.User, category resx.ResEventType) (*db.SelectDataset, db.Expression, resx.ResChangeRowFunc) { return nil, nil, func(rows []resx.ResChangeRow) error { gotRows = rows return nil } }, nil, ) // 模拟调用方自己已经开了一个事务 mock.ExpectBegin() tx, err := res.DB().Begin() t.Require().NoError(err) // 这里不再有第二次 ExpectBegin:证明 UseTx 发现 dp.tx 已经非 nil 就不会再自己开一个 mock.ExpectExec("INSERT").WillReturnResult(sqlmock.NewResult(1, 1)) _, err = res.GetDBTable(base.GetAnonymous(), req.WithTx(tx), req.WithPermission(req.ResAll)).Insert(). Rows(db.Record{"name": "赵六"}). Executor().Exec() t.NoError(err) // After 不会自动提交,调用方自己提交 mock.ExpectCommit() t.NoError(tx.Commit()) t.NoError(mock.ExpectationsWereMet()) t.Require().Len(gotRows, 1) t.Same(tx, gotRows[0].Tx(), "row.Tx() 应该是调用方传入的那个事务,不是 UseTx 自动开的") }