From 09f4c40d6709c49e8deb9ec09c54c45636f97d9e Mon Sep 17 00:00:00 2001 From: what Date: Wed, 22 Jul 2026 09:11:50 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20resx=20=E5=8C=85=E5=89=A9?= =?UTF-8?q?=E4=BD=99=E5=AE=9E=E7=8E=B0:=E5=AD=97=E6=AE=B5/=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E5=AD=97=E6=AE=B5/=E8=84=B1=E6=95=8F/=E5=8F=98?= =?UTF-8?q?=E6=9B=B4=E8=A1=8C=E7=AD=89=E7=B1=BB=E5=9E=8B=E5=8F=8A=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit res_field.go/res_field_option.go(字段定义)、res_query_field.go/res_query_field_option.go(查询字段)、res_change_row.go(变更行)、res_mask_field.go(字段脱敏标记)及对应测试文件,配合此前已提交的 resource.go/res_interceptor.go 组成完整的 resx 资源实现包。 --- resx/res_change_row.go | 126 ++++++ resx/res_field.go | 355 +++++++++++++++++ resx/res_field_option.go | 51 +++ resx/res_interceptor_test.go | 700 +++++++++++++++++++++++++++++++++ resx/res_mask_field.go | 83 ++++ resx/res_mask_field_test.go | 590 +++++++++++++++++++++++++++ resx/res_normalize_test.go | 234 +++++++++++ resx/res_query_field.go | 168 ++++++++ resx/res_query_field_option.go | 32 ++ resx/resource_test.go | 88 +++++ 10 files changed, 2427 insertions(+) create mode 100644 resx/res_change_row.go create mode 100644 resx/res_field.go create mode 100644 resx/res_field_option.go create mode 100644 resx/res_interceptor_test.go create mode 100644 resx/res_mask_field.go create mode 100644 resx/res_mask_field_test.go create mode 100644 resx/res_normalize_test.go create mode 100644 resx/res_query_field.go create mode 100644 resx/res_query_field_option.go create mode 100644 resx/resource_test.go diff --git a/resx/res_change_row.go b/resx/res_change_row.go new file mode 100644 index 0000000..1444550 --- /dev/null +++ b/resx/res_change_row.go @@ -0,0 +1,126 @@ +package resx + +import ( + "database/sql" + + "github.com/samber/lo" + + "git.fsdpf.net/go/db" + "git.fsdpf.net/go/reflux" + "git.fsdpf.net/go/req" +) + +// ResEventType 资源操作类型:读(SELECT)+ 写(INSERT/UPDATE/DELETE) +type ResEventType string + +const ( + ResEventSelect ResEventType = "SELECT" + ResEventInsert ResEventType = "INSERT" + ResEventUpdate ResEventType = "UPDATE" + ResEventDelete ResEventType = "DELETE" +) + +// ResChangeEvent 描述一次写操作触发的变更:类型、资源、用户、操作结果,不对应具体某一行数据。 +// 只对应写操作,Type() 恒为 INSERT/UPDATE/DELETE 之一,不会是 ResEventSelect。 +type ResChangeEvent interface { + Type() ResEventType // INSERT | UPDATE | DELETE + Res() req.Resource // 触发这次变更的资源 + User() req.User // 触发这次变更的用户 + Result() sql.Result // 本次写操作的执行结果 +} + +// resChangeEvent 是 ResChangeEvent 的具体实现 +type resChangeEvent struct { + typ ResEventType + res req.Resource + user req.User + result sql.Result +} + +func (e resChangeEvent) Type() ResEventType { return e.typ } +func (e resChangeEvent) Res() req.Resource { return e.res } +func (e resChangeEvent) User() req.User { return e.user } +func (e resChangeEvent) Result() sql.Result { return e.result } + +// NewResChangeEvent 构造一个 ResChangeEvent +func NewResChangeEvent(typ ResEventType, res req.Resource, u req.User, result sql.Result) ResChangeEvent { + return resChangeEvent{typ: typ, res: res, user: u, result: result} +} + +// ResChangeRow 描述一次资源写操作中某一行的变更内容。只对应写操作,Type() 恒为 +// INSERT/UPDATE/DELETE 之一,不会是 ResEventSelect。 +type ResChangeRow interface { + Type() ResEventType // INSERT | UPDATE | DELETE + Res() req.Resource // 触发这次变更的资源 + User() req.User // 触发这次变更的用户 + ID() int64 // 行主键(INSERT 时也是已经算好的自增 id) + Old() reflux.R // 变更前快照,INSERT 时为 reflux.Nil + New() reflux.R // 本次写入值,DELETE 时为 reflux.Nil + Cur() reflux.R // Old 与 New 合并后的视图(New 覆盖 Old 同名字段) + // Tx 与原始写操作共享的事务,仅当调用方要写入的资源与原始资源使用同一个数据库连接时才可复用。 + Tx() *db.TxDatabase +} + +type resChangeRow struct { + typ ResEventType + res req.Resource + user req.User + id int64 + old map[string]any + new map[string]any + tx *db.TxDatabase +} + +func (r resChangeRow) Type() ResEventType { return r.typ } +func (r resChangeRow) Res() req.Resource { return r.res } +func (r resChangeRow) User() req.User { return r.user } +func (r resChangeRow) ID() int64 { return r.id } +func (r resChangeRow) Tx() *db.TxDatabase { return r.tx } + +func (r resChangeRow) Old() reflux.R { + if len(r.old) == 0 { + return reflux.Nil + } + return reflux.New(r.old) +} + +func (r resChangeRow) New() reflux.R { + if len(r.new) == 0 { + return reflux.Nil + } + return reflux.New(r.new) +} + +func (r resChangeRow) Cur() reflux.R { + if len(r.old) == 0 && len(r.new) == 0 { + return reflux.Nil + } + return reflux.New(lo.Assign(r.old, r.new)) +} + +// ResChangeRowOption 用于设置 NewResChangeRow 里非必填的字段 +type ResChangeRowOption func(r *resChangeRow) + +// WithOld 设置变更前快照,不设置则为 reflux.Nil +func WithOld(old map[string]any) ResChangeRowOption { + return func(r *resChangeRow) { r.old = old } +} + +// WithNew 设置本次写入值,不设置则为 reflux.Nil +func WithNew(new map[string]any) ResChangeRowOption { + return func(r *resChangeRow) { r.new = new } +} + +// WithTx 设置与原始写操作共享的事务,不设置则为 nil +func WithTx(tx *db.TxDatabase) ResChangeRowOption { + return func(r *resChangeRow) { r.tx = tx } +} + +// NewResChangeRow 构造一个 ResChangeRow +func NewResChangeRow(typ ResEventType, res req.Resource, u req.User, id int64, opts ...ResChangeRowOption) ResChangeRow { + r := resChangeRow{typ: typ, res: res, user: u, id: id} + for _, opt := range opts { + opt(&r) + } + return r +} diff --git a/resx/res_field.go b/resx/res_field.go new file mode 100644 index 0000000..1546a44 --- /dev/null +++ b/resx/res_field.go @@ -0,0 +1,355 @@ +package resx + +import ( + "encoding/json" + "fmt" + "reflect" + "strconv" + "strings" + + "git.fsdpf.net/go/db" + "git.fsdpf.net/go/db/schema" + "git.fsdpf.net/go/req" + "github.com/spf13/cast" +) + +// ResField 是 req.ResField 的具体实现,也是 QueryField 的底子(QueryField 匿名嵌入它)。 +// 原来放在 contracts-v2/base,这里搬过来是因为 resx 自己(MaskField 的懒解析、QueryField.ToSql +// 的字段级脱敏判断)需要用到它,而 resx 不能反过来 import contracts/base(contracts/base 依赖 +// contracts 根包,根包又依赖 resx,会成环)。contracts-v2/base.ResField 现在是指向这里的类型 +// 别名,兼容原有引用。 +// +// 字段全部私有,只能通过 NewResField(opts...) + FieldWith* 构造——避免调用方绕开 +// GetRoles()/ToValue() 等方法直接摆弄底层数据。JSON 序列化/反序列化(资源缓存从数据库 +// JSON_ARRAYAGG 结果里加载字段配置,见 framework-v2 的 init_res_cache.go)靠手写的 +// MarshalJSON/UnmarshalJSON 维持原来的 wire 格式,不依赖 encoding/json 对导出字段的默认反射。 +type ResField struct { + uuid string + name string + code string + codeResource string + dataType req.ResDataType + length string + comment string + defaultValue string + virtual bool + virtualExpr string + // roles 配置了"能读写该字段真实值"的角色列表,为空表示不限制。见 req.ResField.GetRoles 注释。 + roles []string +} + +// NewResField 创建一个 ResField。code/codeResource 是必填的位置参数(每一个字段都要设置,不适合 +// 当可选项);name 不显式用 FieldWithName 设置的话默认等于 code,uuid 不显式用 FieldWithUuid 设置 +// 的话默认是 codeResource+code——这两个字段在测试和一些一次性计算字段的场景里经常不关心具体取值, +// 只是不能为空,给个确定性的默认值省得每次都要传。dataType 放进 FieldWithDataType 而不是位置参数: +// 很多调用点(临时拼一个查询字段、只关心 Code/CodeResource)并不知道或不关心具体数据类型,当位置 +// 参数的话每次都要传个占位值,不如做成可选项。FieldWith* 选项定义在 res_field_option.go。 +func NewResField(code, codeResource string, opts ...ResFieldOption) req.ResField { + f := ResField{ + code: code, + codeResource: codeResource, + name: code, + uuid: codeResource + code, + } + for _, opt := range opts { + opt(&f) + } + return f +} + +func (this ResField) ToStructField(tags ...string) reflect.StructField { + var typ reflect.Type + + fCode := this.code + fTag := `db:"` + fCode + `" json:"` + fCode + `"` + + if len(tags) > 0 { + fTag = strings.Join(tags, " ") + " " + fTag + } + + switch this.dataType { + case req.ResString, req.ResText, req.ResEnum, + req.ResTimestamp, req.ResDate, req.ResDatetime: + typ = reflect.TypeOf(string("")) + case req.ResInteger, req.ResSmallInteger: + typ = reflect.TypeOf(int64(0)) + case req.ResDecimal: + typ = reflect.TypeOf(float64(0)) + case req.ResBoolean: + typ = reflect.TypeOf(true) + case req.ResJson: + if this.defaultValue != "" && this.defaultValue[0:1] == "[" { + typ = reflect.TypeOf([]any{}) + } else { + typ = reflect.TypeOf(map[string]any{}) + } + case req.ResVector: + typ = reflect.TypeOf([]float64{}) + } + + return reflect.StructField{ + Name: strings.ToUpper(fCode[:1]) + fCode[1:], + Tag: reflect.StructTag(fTag), + Type: typ, + } +} + +func (this ResField) IsVirtual() bool { + return this.virtual +} + +func (this ResField) GetRoles() []string { + return this.roles +} + +func (this ResField) GetUuid() string { + return this.uuid +} + +func (this ResField) GetCode() string { + return this.code +} + +func (this ResField) GetCodeResource() string { + return this.codeResource +} + +func (this ResField) GetName() string { + return this.name +} + +func (this ResField) GetDataType() req.ResDataType { + return this.dataType +} + +func (this ResField) GetLength() string { + return this.length +} + +func (this ResField) GetComment() string { + return this.comment +} + +func (this ResField) GetDefault() string { + return this.defaultValue +} + +func (this ResField) GetVirtualExpr() string { + return this.virtualExpr +} + +func (this ResField) GetQueryDataType() req.RouteParamType { + switch this.GetDataType() { + case req.ResEnum, + req.ResTimestamp, req.ResDate, + req.ResDatetime, req.ResString, + req.ResText: + return req.ReqString + case req.ResInteger, req.ResSmallInteger: + return req.ReqInteger + case req.ResDecimal: + return req.ReqFloat + case req.ResBoolean: + return req.ReqBool + case req.ResJson: + if this.defaultValue != "" && this.defaultValue[0:1] == "[" { + return req.ReqArray + } + return req.ReqJson + case req.ResVector: + return req.ReqArray + } + return req.ReqString +} + +func (this ResField) ToValue(v any) any { + if this.dataType == req.ResJson { + if v == nil { + if this.defaultValue != "" && this.defaultValue[0:1] == "[" { + return db.V("[]") + } else if this.defaultValue != "" && this.defaultValue[0:1] == "{" { + return db.V("{}") + } else if this.defaultValue == "" { + return db.V("{}") + } + return this.GetRawDefault() + } + + if str, ok := v.(string); ok { + return str + } else if raw, ok := v.(db.Expression); ok { + return raw + } else if b, err := json.Marshal(v); err == nil { + return string(b) + } else { + panic(fmt.Sprintf("%s, 类型转换错误, %s", this.code, err)) + } + } + + if this.dataType == req.ResVector { + if v == nil { + return db.V(nil) + } + var parts []string + switch floats := v.(type) { + case []float32: + parts = make([]string, len(floats)) + for i, f := range floats { + parts[i] = strconv.FormatFloat(float64(f), 'g', -1, 32) + } + case []float64: + parts = make([]string, len(floats)) + for i, f := range floats { + parts[i] = strconv.FormatFloat(f, 'g', -1, 64) + } + default: + return v + } + return db.L("[" + strings.Join(parts, ", ") + "]") + } + + return v +} + +func (this ResField) GetRawDefault() db.Expression { + if this.dataType == req.ResJson { + if this.defaultValue != "" && this.defaultValue[0:1] == "[" { + return db.V("[]") + } else if this.defaultValue != "" && this.defaultValue[0:1] == "{" { + return db.V("{}") + } else if this.defaultValue == "" { + return db.V("{}") + } + } else if this.dataType == req.ResBoolean { + if v, _ := strconv.ParseBool(this.defaultValue); v { + return db.V(true) + } + return db.V(false) + } + + if len(this.defaultValue) > 4 && strings.ToLower(this.defaultValue[0:4]) == "sql:" { + return db.L(this.defaultValue[4:]) + } + + if this.defaultValue == "" { + if this.GetDataType() == req.ResDate || this.GetDataType() == req.ResDatetime { + return db.V(nil) + } + return db.V("") + } + + if strings.ToUpper(this.defaultValue) == "CURRENT_TIMESTAMP" { + return db.L(this.defaultValue) + } + + return db.V(this.defaultValue) +} + +func (this ResField) ToBlueprint(table *schema.Blueprint) (temp *schema.ColumnDefinition) { + switch this.code { + case "id": + return table.BigIncrements("id").AutoIncrement().Comment("ID") + case "enabled": + return table.Boolean("enabled").Default("1").Comment("是否有效") + case "created_user": + return table.Char("created_user", 36).Default("00000000-0000-0000-0000-000000000000").Comment("创建者") + case "owned_user": + return table.Char("owned_user", 36).Default("00000000-0000-0000-0000-000000000000").Comment("拥有者") + case "created_at": + return table.Timestamp("created_at").UseCurrent().Comment("创建时间") + case "updated_at": + return table.Timestamp("updated_at").UseCurrent().Default(db.L("ON UPDATE CURRENT_TIMESTAMP")).Comment("更新时间") + case "deleted_at": + return table.DateTime("deleted_at").Nullable().Comment("删除时间") + } + + isNull := false + comment := this.name + def := any(this.defaultValue) + + if this.comment != "" { + comment += " [ " + strings.Trim(this.comment, `' "`) + " ]" + } + + switch this.dataType { + case "string": + len := 255 + if v, err := strconv.Atoi(strings.Trim(this.length, `' "`)); err == nil { + len = v + } + temp = table.String(this.code, len) + case "smallInteger": + // integer 默认长度 4 + temp = table.SmallInteger(this.code) + case "boolean": + // integer 默认长度 1 + temp = table.Boolean(this.code) + case "integer": + // integer 默认长度 11 + temp = table.Integer(this.code) + case "date", "dateTime", "timestamp": + if this.dataType == "date" { + temp = table.Date(this.code) + } else { + temp = table.DateTime(this.code) + } + + if strings.ToUpper(this.defaultValue) == "SQL:CURRENT_TIMESTAMP" { + def = db.L("CURRENT_TIMESTAMP") + } else if def == "" { + isNull = true + } + case "decimal": + allowed := strings.SplitN(this.length, ",", 2) + total := 8 + places := 2 + if v, err := strconv.Atoi(strings.Trim(allowed[0], `' "`)); err == nil { + total = v + } + if v, err := strconv.Atoi(strings.Trim(allowed[1], `' "`)); err == nil { + places = v + } + temp = table.Decimal(this.code, total, places) + case "enum": + allowed := []string{} + for _, v := range strings.Split(this.length, ",") { + allowed = append(allowed, strings.Trim(v, `' "`)) + } + temp = table.Enum(this.code, allowed) + case "json": + temp = table.Json(this.code) + isNull = true + case "text": + temp = table.Text(this.code) + isNull = true + case "vector": + temp = table.Vector(this.code, cast.ToInt(this.length)) + isNull = false + default: + panic(fmt.Sprintf("Unknown type: %s", this.dataType)) + } + + if this.IsVirtual() { + temp.VirtualAs(this.virtualExpr) + } + + if isNull { + temp.Nullable() + } else { + temp.Default(def) + } + + temp.Comment(comment) + + return temp +} + +func (this ResField) ToQueryField(t req.RouteParamType, alias string, options byte) req.QueryField { + o := &QueryField{ + ResField: this, + typ: t, + alias: alias, + } + + return o.SetOptions(options) +} diff --git a/resx/res_field_option.go b/resx/res_field_option.go new file mode 100644 index 0000000..ba8da5e --- /dev/null +++ b/resx/res_field_option.go @@ -0,0 +1,51 @@ +package resx + +import "git.fsdpf.net/go/req" + +// ResFieldOption 是 NewResField 的选项函数类型。 +type ResFieldOption func(*ResField) + +// FieldWithUuid 设置字段的 uuid,不设置默认是 codeResource+code。 +func FieldWithUuid(v string) ResFieldOption { + return func(f *ResField) { f.uuid = v } +} + +// FieldWithName 设置字段的显示名称,不设置默认等于 code。 +func FieldWithName(v string) ResFieldOption { + return func(f *ResField) { f.name = v } +} + +// FieldWithDataType 设置字段的数据类型。 +func FieldWithDataType(v req.ResDataType) ResFieldOption { + return func(f *ResField) { f.dataType = v } +} + +// FieldWithLength 设置字段的长度/精度配置(含义因 DataType 而异,见 ToBlueprint)。 +func FieldWithLength(v string) ResFieldOption { + return func(f *ResField) { f.length = v } +} + +// FieldWithComment 设置字段的数据库列注释。 +func FieldWithComment(v string) ResFieldOption { + return func(f *ResField) { f.comment = v } +} + +// FieldWithDefault 设置字段的默认值原始配置("sql:xxx" 前缀表示 SQL 表达式,见 GetRawDefault)。 +func FieldWithDefault(v string) ResFieldOption { + return func(f *ResField) { f.defaultValue = v } +} + +// FieldWithVirtual 设置字段是否是虚拟列(VirtualAs)。 +func FieldWithVirtual(v bool) ResFieldOption { + return func(f *ResField) { f.virtual = v } +} + +// FieldWithVirtualExpr 设置虚拟列的表达式(仅在 FieldWithVirtual(true) 时有意义)。 +func FieldWithVirtualExpr(v string) ResFieldOption { + return func(f *ResField) { f.virtualExpr = v } +} + +// FieldWithRoles 设置"能读写该字段真实值"的角色列表,见 req.ResField.GetRoles 注释。 +func FieldWithRoles(roles ...string) ResFieldOption { + return func(f *ResField) { f.roles = roles } +} diff --git a/resx/res_interceptor_test.go b/resx/res_interceptor_test.go new file mode 100644 index 0000000..57d9d1c --- /dev/null +++ b/resx/res_interceptor_test.go @@ -0,0 +1,700 @@ +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()).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()).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()).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()).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()).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()).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()).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()).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()).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()).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()).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()).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()).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()).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()).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()).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 + }) + + res := resx.New(app, "User", "users", + resx.WithConn("default"), + resx.WithVirtual(true), + resx.WithFields(resx.NewResField("name", "User", resx.FieldWithName("姓名"), resx.FieldWithDataType(req.ResString))), + ) + + // 实际生成的 SQL(虚拟资源用字面量拼表名,且没有 owned_user 权限条件): + // UPDATE (users) AS `User` SET `name`='新名字' WHERE (`id` = 1) + sql, _, _ := res.GetDBTable(base.GetAnonymous()).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()).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()).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()).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)).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 自动开的") +} diff --git a/resx/res_mask_field.go b/resx/res_mask_field.go new file mode 100644 index 0000000..e363a38 --- /dev/null +++ b/resx/res_mask_field.go @@ -0,0 +1,83 @@ +package resx + +import ( + "fmt" + + "git.fsdpf.net/go/db/exp" + "git.fsdpf.net/go/req" +) + +// MaskField 用 "table.col" 或裸列名 "col" 标记一个列需要按对应 req.ResField 的 Roles 做字段级 +// 权限脱敏。dataProcessor.maskSelect 只处理带这个标记的列,未标记的列(包括看起来像同名字段的 +// 普通列)一律不处理——标记是显式的,不靠猜。 +// +// 不带表名的裸列名(比如 "salary")表示当前资源自己的字段;带表名的(比如 "Department.salary") +// 里的 table 部分通常是查询里用的别名(当前资源自己的默认别名,或者 JOIN 进来的资源用的别名)。 +// 这里不会立即去查是哪个资源、哪个字段:MaskField 被调用的时候,SELECT 列表所在的 SelectDataset +// 可能还没拼完(FROM/JOIN 子句可能还没加上去),而且别名也不一定等于资源自己的 code(比如同一个 +// 资源自连接查询,两个别名不可能都等于资源自己的 code)——这两种情况都得等查询真正执行前 +// (dataProcessor.maskSelect,在 exec.Hooks.Before 里跑,那时候才有完整的 FROM/JOIN 子句)才能 +// 正确解析出别名对应的真实表名,再去 container 里的 req.GetResource 按 code/table/uuid 查资源、 +// 按 col 查字段。 +// +// 用法:resx.MaskField("salary").As("salary")、resx.MaskField("Department.salary").As("salary") +func MaskField(ident string) exp.IdentifierExpression { + parsed := exp.ParseIdentifier(ident) + + code, isStringCode := parsed.GetCol().(string) + if !isStringCode || code == "" { + panic(fmt.Errorf("resx.MaskField: ident %q 不是合法的列名", ident)) + } + + return lazyMaskedIdent{IdentifierExpression: parsed, codeResource: parsed.GetTable(), code: code} +} + +// lazyMaskResolver 是 lazyMaskedIdent/lazyMaskedAliased 共用的类型断言接口:dataProcessor.maskSelect +// 靠它从 SELECT 列表里挑出被 MaskField 标记过、还没解析成具体 req.ResField 的列。codeResource 为 +// 空表示裸列名,对应当前资源自己的字段。 +// +// 这里特意不叫 alias:MaskField("table.col") 里 table 这部分,语义上对应的是 +// req.ResField.GetCodeResource()(哪个资源),不是 SQL 里"起别名"的 alias(比如 .As(...) 里的 +// 输出列名)——这个包里两种"alias"都会出现(resolveJoinTable 是在拿它去 FROM/JOIN 子句里当 SQL +// 别名搜,那里用 alias 这个词没问题;但站在 MaskField 被构造出来这一刻,它就是资源标识,叫 +// codeResource 才不会跟 .As(...) 那个"别名"混在一起)。 +type lazyMaskResolver interface { + lazyMaskField() (codeResource, code string) +} + +// lazyMaskedIdent 包装一个 exp.IdentifierExpression,同时打上"待解析"标记(只有资源标识+字段 +// code,还没查出 req.ResField)。除 As() 外的其它方法(Eq()/In()/Table() 等)都是内嵌接口自动 +// 透传,不用手写。 +type lazyMaskedIdent struct { + exp.IdentifierExpression + codeResource string + code string +} + +func (m lazyMaskedIdent) lazyMaskField() (string, string) { return m.codeResource, m.code } + +// As 必须单独实现:IdentifierExpression.As() 返回的是 exp.AliasedExpression,是另一个类型, +// 如果不单独处理,直接透传内嵌值的 As() 会丢掉标记(调用方几乎总会紧跟着调用 As() 给列起别名)。 +func (m lazyMaskedIdent) As(val interface{}) exp.AliasedExpression { + return lazyMaskedAliased{AliasedExpression: m.IdentifierExpression.As(val), codeResource: m.codeResource, code: m.code} +} + +// lazyMaskedAliased 包装一个 exp.AliasedExpression(MaskField(...).As(...) 链式调用之后的结果), +// 同时保留"待解析"标记 +type lazyMaskedAliased struct { + exp.AliasedExpression + codeResource string + code string +} + +func (m lazyMaskedAliased) lazyMaskField() (string, string) { return m.codeResource, m.code } + +// hasFieldAccess 判断 u 是否有权限读写 field 的真实值:field.GetRoles() 为空表示不限制,非空时 +// u 的角色只要有一个在列表里就算有权限(复用 req.User.HasUserRoles,同时拿到超级管理员的豁免) +func hasFieldAccess(field req.ResField, u req.User) bool { + roles := field.GetRoles() + if len(roles) == 0 { + return true + } + return u.HasUserRoles(roles...) +} diff --git a/resx/res_mask_field_test.go b/resx/res_mask_field_test.go new file mode 100644 index 0000000..5428dec --- /dev/null +++ b/resx/res_mask_field_test.go @@ -0,0 +1,590 @@ +package resx_test + +import ( + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/samber/do/v2" + "github.com/samber/lo" + "github.com/stretchr/testify/suite" + + "git.fsdpf.net/go/db" + "git.fsdpf.net/go/db/engine" + "git.fsdpf.net/go/db/exp" + "git.fsdpf.net/go/req" + "git.fsdpf.net/go/req/resx" + + _ "git.fsdpf.net/go/db/dialect/mysql" +) + +// maskFieldTest 验证字段级权限(ResField.GetRoles):SELECT 时按权限把字段替换成 MaskFunc 给的 +// 哨兵值(resx.MaskField 显式标记的列才处理,未标记的列即使字段本身受限也不处理),INSERT/ +// UPDATE 时把没有写权限的字段静默丢弃(见 normalizeSaveValue)。 +type maskFieldTest struct { + suite.Suite +} + +func TestMaskField(t *testing.T) { + suite.Run(t, new(maskFieldTest)) +} + +// testUser 是一个可以自定义角色的 req.User 测试替身;contracts-v2/base 的 user 类型是私有的, +// 没有导出"构造一个自定义角色用户"的方式,这里按同样的语义(含超级管理员豁免)自己实现一个。 +type testUser struct { + roles []string +} + +func (u testUser) ID() int64 { return 1 } +func (u testUser) Uuid() string { return "test-user-uuid" } +func (u testUser) Username() string { return "test" } +func (u testUser) Nickname() string { return "测试用户" } +func (u testUser) GetUserInfo() map[string]any { return nil } +func (u testUser) Roles() []string { return u.roles } +func (u testUser) IsAnonymous() bool { return false } +func (u testUser) Runtime() req.UserRuntime { return req.NewUserRuntime() } +func (u testUser) HasUserRoles(roles ...string) bool { + return lo.Contains(u.roles, "ffffffff-ffff-ffff-ffff-ffffffffffff") || len(lo.Intersect(u.roles, roles)) > 0 +} + +// newApp 构造一个绑定了 sqlmock 的容器,同时注册 req.GetResource——用同一个容器构造出来的资源, +// 按 code/table 都能查到(MaskField 解析带表名的别名要用它)。register 用来把资源加进查找表, +// 调用方在 resx.New(...) 之后手动调一次。 +func (t *maskFieldTest) newApp() (do.Injector, sqlmock.Sqlmock, func(req.Resource)) { + 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 + }) + + registry := map[string]req.Resource{} + do.Provide(app, func(i do.Injector) (req.GetResource, error) { + return func(code string) (req.Resource, bool) { + r, ok := registry[code] + return r, ok + }, nil + }) + + register := func(res req.Resource) { + registry[res.GetCode()] = res + registry[res.GetTable()] = res + } + + return app, mock, register +} + +// newResource 构造一个绑定了 sqlmock 的 User 资源,字段列表自定义 +func (t *maskFieldTest) newResource(fields ...req.ResField) (req.Resource, sqlmock.Sqlmock) { + app, mock, register := t.newApp() + + res := resx.New(app, "User", "users", + resx.WithConn("default"), + resx.WithFields(fields...), + ) + register(res) + + return res, mock +} + +var ( + nameField = resx.NewResField("name", "User", resx.FieldWithName("姓名"), resx.FieldWithDataType(req.ResString)) + salaryField = resx.NewResField("salary", "User", resx.FieldWithName("薪资"), resx.FieldWithRoles("hr"), resx.FieldWithDataType(req.ResInteger)) + deptBudgetFld = resx.NewResField("budget", "Department", resx.FieldWithName("预算"), resx.FieldWithRoles("finance"), resx.FieldWithDataType(req.ResInteger)) + categorySecretFld = resx.NewResField("secret", "Category", resx.FieldWithName("秘密"), resx.FieldWithRoles("admin"), resx.FieldWithDataType(req.ResInteger)) + companyBudgetFld = resx.NewResField("budget", "Company", resx.FieldWithName("预算"), resx.FieldWithRoles("finance"), resx.FieldWithDataType(req.ResInteger)) + managerSalaryFld = resx.NewResField("salary", "Manager", resx.FieldWithName("薪资"), resx.FieldWithRoles("hr"), resx.FieldWithDataType(req.ResInteger)) +) + +// newJoinedApp 构造两个互相独立的资源(User -> users、Department -> departments),都注册进同一个 +// 容器的 req.GetResource,方便下面几个 JOIN 相关用例复用 +func (t *maskFieldTest) newJoinedApp() (user, dept req.Resource) { + app, _, register := t.newApp() + + dept = resx.New(app, "Department", "departments", + resx.WithConn("default"), + resx.WithFields(deptBudgetFld), + ) + register(dept) + + user = resx.New(app, "User", "users", + resx.WithConn("default"), + resx.WithFields(nameField), + ) + register(user) + + return user, dept +} + +// newChainedJoinApp 构造管理结构里的链式关联(User -> Department -> Company):Company 不是直接 +// 挂在 User 下面,是通过 Department 关联的第二个 JOIN 子句——用来验证 resolveJoinTable 遍历 +// clauses.Joins() 时不是只认第一个 JOIN,后面链上的也能正确解析出别名对应的真实表。 +func (t *maskFieldTest) newChainedJoinApp() (user, dept, company req.Resource) { + app, _, register := t.newApp() + + company = resx.New(app, "Company", "companies", + resx.WithConn("default"), + resx.WithFields(companyBudgetFld), + ) + register(company) + + dept = resx.New(app, "Department", "departments", + resx.WithConn("default"), + resx.WithFields(deptBudgetFld), + ) + register(dept) + + user = resx.New(app, "User", "users", + resx.WithConn("default"), + resx.WithFields(nameField), + ) + register(user) + + return user, dept, company +} + +// newStarJoinApp 构造管理结构里的并列关联(User -> Department、User -> Manager):Department 和 +// Manager 都直接挂在 User 下面,是两条独立的 JOIN 子句,不是链式关联——用来验证同一个查询里, +// 两个不同 JOIN 别名各自独立解析,互不干扰。 +func (t *maskFieldTest) newStarJoinApp() (user, dept, manager req.Resource) { + app, _, register := t.newApp() + + dept = resx.New(app, "Department", "departments", + resx.WithConn("default"), + resx.WithFields(deptBudgetFld), + ) + register(dept) + + manager = resx.New(app, "Manager", "managers", + resx.WithConn("default"), + resx.WithFields(managerSalaryFld), + ) + register(manager) + + user = resx.New(app, "User", "users", + resx.WithConn("default"), + resx.WithFields(nameField), + ) + register(user) + + return user, dept, manager +} + +// -------- SELECT * 默认展开 -------- + +// TestDefaultSelect_MasksRestrictedField 没有权限时,SELECT * 展开后受限字段被替换成哨兵值 +func (t *maskFieldTest) TestDefaultSelect_MasksRestrictedField() { + res, _ := t.newResource(nameField, salaryField) + + sql, _, _ := res.GetDBTable(testUser{}).Select(db.Star()).Executor().ToSQL() + + t.Contains(sql, "`name`") + t.Contains(sql, "-999999999 AS `salary`", "受限字段应该被替换成内置的数字哨兵值,而不是裸列") +} + +// TestDefaultSelect_RoleMatchShowsRealColumn 用户角色命中 Roles 时,字段按真实列查询,不脱敏 +func (t *maskFieldTest) TestDefaultSelect_RoleMatchShowsRealColumn() { + res, _ := t.newResource(nameField, salaryField) + + sql, _, _ := res.GetDBTable(testUser{roles: []string{"hr"}}).Select(db.Star()).Executor().ToSQL() + + // 没有字段需要脱敏,直接维持原来的 SELECT *,不会被改写成展开的列表 + t.Contains(sql, "SELECT *") + t.NotContains(sql, "-999999999") +} + +// TestDefaultSelect_NoRestrictedFieldsLeavesWildcard 没有任何字段配置 Roles 时,SELECT * 不展开、不改写 +func (t *maskFieldTest) TestDefaultSelect_NoRestrictedFieldsLeavesWildcard() { + res, _ := t.newResource(nameField) + + sql, _, _ := res.GetDBTable(testUser{}).Select(db.Star()).Executor().ToSQL() + + t.Contains(sql, "SELECT *") +} + +// -------- 显式列:只处理 resx.MaskField 标记过的列 -------- + +// TestExplicitColumn_MaskFieldMarkedColumnGetsMasked 标记过的列,没权限时会被替换 +func (t *maskFieldTest) TestExplicitColumn_MaskFieldMarkedColumnGetsMasked() { + res, _ := t.newResource(nameField, salaryField) + + sql, _, _ := res.GetDBTable(testUser{}). + Select(db.C("name"), resx.MaskField("salary").As("salary")). + Executor().ToSQL() + + t.Contains(sql, "`name`") + t.Contains(sql, "-999999999 AS `salary`") +} + +// TestExplicitColumn_UnmarkedColumnNeverMasked 没有用 MaskField 标记的列,即使字段本身受限也 +// 原样保留——标记是显式的,dataProcessor 不会替调用方去猜哪一列对应哪个字段 +func (t *maskFieldTest) TestExplicitColumn_UnmarkedColumnNeverMasked() { + res, _ := t.newResource(nameField, salaryField) + + sql, _, _ := res.GetDBTable(testUser{}). + Select(db.C("salary")). + Executor().ToSQL() + + t.Contains(sql, "`salary`") + t.NotContains(sql, "-999999999") +} + +// TestQualifiedWildcard_OwnAliasExpandsAndMasks db.T(alias).All()(限定到某个别名的全部列)如果 +// 限定的别名就是当前资源自己(GetDBTable 默认用资源 code 当别名),效果等价于裸的 db.Star(), +// 同样会展开成显式字段列表、按权限脱敏——不是只有 db.Star() 才会触发展开逻辑 +func (t *maskFieldTest) TestQualifiedWildcard_OwnAliasExpandsAndMasks() { + res, _ := t.newResource(nameField, salaryField) + + sql, _, _ := res.GetDBTable(testUser{}). + Select(db.T("User").All()). + Executor().ToSQL() + + t.Contains(sql, "`name`") + t.Contains(sql, "-999999999 AS `salary`") +} + +// TestQualifiedWildcard_OtherAliasNeverMasked db.T(alias).All() 如果限定的是别的别名(比如 JOIN +// 进来的另一个资源),不会被当成"选当前资源自己的全部列",不会展开、不会脱敏——这属于"JOIN 进来 +// 的字段够不到"的已知限制,不是这里要解决的问题 +func (t *maskFieldTest) TestQualifiedWildcard_OtherAliasNeverMasked() { + res, _ := t.newResource(nameField, salaryField) + + sql, _, _ := res.GetDBTable(testUser{}). + Select(db.T("Department").All()). + Executor().ToSQL() + + t.NotContains(sql, "-999999999") +} + +// TestExplicitColumn_CustomAliasPreserved MaskField(...).As(alias) 替换后仍然用调用方指定的别名, +// 不是恒用字段码 +func (t *maskFieldTest) TestExplicitColumn_CustomAliasPreserved() { + res, _ := t.newResource(salaryField) + + sql, _, _ := res.GetDBTable(testUser{}). + Select(resx.MaskField("salary").As("employee_salary")). + Executor().ToSQL() + + t.Contains(sql, "AS `employee_salary`") + t.NotContains(sql, "AS `salary`") +} + +// TestExplicitColumn_JoinedResourceFieldMasked MaskField("Department.budget") 这种带表名的形式, +// 在 JOIN 查询里能正确解析成 Department 资源的 budget 字段——别名 "Department" 换成真实表名 +// "departments",再用 req.GetResource 按 table 查到资源,不是靠"别名就是资源 code"硬编码猜的。 +// JOIN 用的是 res.GetTableExpr().As(alias)(跟 orm-v2 实际 JOIN 构造方式一致),命中的是 +// aliasedRealTable 里"字面量包了一层 identifier"那条分支。 +func (t *maskFieldTest) TestExplicitColumn_JoinedResourceFieldMasked() { + user, dept := t.newJoinedApp() + + sql, _, _ := user.GetDBTable(testUser{}). + LeftJoin(dept.GetTableExpr().As("Department"), db.On(db.I("User.dept_id").Eq(db.I("Department.id")))). + Select(db.C("name"), resx.MaskField("Department.budget").As("budget")). + Executor().ToSQL() + + t.Contains(sql, "`name`") + t.Contains(sql, "-999999999 AS `budget`") +} + +// TestExplicitColumn_JoinedResourceFieldRoleMatchNotMasked 同样是 JOIN 进来的字段,用户角色命中 +// Department.budget 的 Roles 时不脱敏,按真实列查询——JOIN 场景下权限判断跟当前资源自己的字段 +// 走的是同一套 hasFieldAccess,不因为是"别的资源的字段"就被跳过或者反而更严格 +func (t *maskFieldTest) TestExplicitColumn_JoinedResourceFieldRoleMatchNotMasked() { + user, dept := t.newJoinedApp() + + sql, _, _ := user.GetDBTable(testUser{roles: []string{"finance"}}). + LeftJoin(dept.GetTableExpr().As("Department"), db.On(db.I("User.dept_id").Eq(db.I("Department.id")))). + Select(db.C("name"), resx.MaskField("Department.budget").As("budget")). + Executor().ToSQL() + + t.Contains(sql, "`budget`") + t.NotContains(sql, "-999999999") +} + +// TestExplicitColumn_DirectIdentifierAliasJoinResolves JOIN 用 db.T(table).As(alias) 直接在 +// identifier 上起别名(不经过 GetTableExpr 那层字面量包装)时,一样能正确解析——命中的是 +// aliasedRealTable 里"Aliased() 直接就是 identifier"那条分支,跟上面字面量包装的分支不是同一条 +// 代码路径,两条都要覆盖到 +func (t *maskFieldTest) TestExplicitColumn_DirectIdentifierAliasJoinResolves() { + user, _ := t.newJoinedApp() + + sql, _, _ := user.GetDBTable(testUser{}). + LeftJoin(db.T("departments").As("Department"), db.On(db.I("User.dept_id").Eq(db.I("Department.id")))). + Select(db.C("name"), resx.MaskField("Department.budget").As("budget")). + Executor().ToSQL() + + t.Contains(sql, "-999999999 AS `budget`") +} + +// TestExplicitColumn_SelfJoinDifferentAliasesResolveIndependently 同一个资源自连接时,两个别名 +// (Child 是当前资源自己默认别名的覆盖,Parent 是 JOIN 进来的第二份自己)都不等于资源自己的 +// code("Category")——这正是当初不能直接"别名当 code 用"、必须找真实表名再查的原因,这里验证两个 +// 别名各自独立解析都是对的,互不影响 +func (t *maskFieldTest) TestExplicitColumn_SelfJoinDifferentAliasesResolveIndependently() { + app, _, register := t.newApp() + + category := resx.New(app, "Category", "categories", + resx.WithConn("default"), + resx.WithFields(categorySecretFld), + ) + register(category) + + sql, _, _ := category.GetDBTable(testUser{}, req.WithAlias("Child")). + LeftJoin(category.GetTableExpr().As("Parent"), db.On(db.I("Child.pid").Eq(db.I("Parent.id")))). + Select( + resx.MaskField("Child.secret").As("child_secret"), + resx.MaskField("Parent.secret").As("parent_secret"), + ). + Executor().ToSQL() + + t.Contains(sql, "-999999999 AS `child_secret`") + t.Contains(sql, "-999999999 AS `parent_secret`") +} + +// TestExplicitColumn_ChainedJoinResolvesThirdTable User -> Department -> Company 链式 JOIN, +// Company 挂在第二个 JOIN 子句上(不是第一个):验证 resolveJoinTable 能找到它,进而查到 Company +// 资源上配置了 Roles 的字段并正确脱敏 +func (t *maskFieldTest) TestExplicitColumn_ChainedJoinResolvesThirdTable() { + user, dept, company := t.newChainedJoinApp() + + sql, _, _ := user.GetDBTable(testUser{}). + LeftJoin(dept.GetTableExpr().As("Department"), db.On(db.I("User.dept_id").Eq(db.I("Department.id")))). + LeftJoin(company.GetTableExpr().As("Company"), db.On(db.I("Department.company_id").Eq(db.I("Company.id")))). + Select(db.C("name"), resx.MaskField("Company.budget").As("budget")). + Executor().ToSQL() + + t.Contains(sql, "`name`") + t.Contains(sql, "-999999999 AS `budget`") +} + +// TestExplicitColumn_StarJoinResolvesEachBranchIndependently User -> Department、User -> Manager +// 两条并列的 JOIN(都直接挂在 User 下面,不是链式关联):命中 Department.budget 的 finance 角色 +// 和没命中 Manager.salary 的 hr 角色分开判断,验证同一次查询里两个 JOIN 分支各自独立解析、互不 +// 串扰 +func (t *maskFieldTest) TestExplicitColumn_StarJoinResolvesEachBranchIndependently() { + user, dept, manager := t.newStarJoinApp() + + sql, _, _ := user.GetDBTable(testUser{roles: []string{"finance"}}). + LeftJoin(dept.GetTableExpr().As("Department"), db.On(db.I("User.dept_id").Eq(db.I("Department.id")))). + LeftJoin(manager.GetTableExpr().As("Manager"), db.On(db.I("User.manager_id").Eq(db.I("Manager.id")))). + Select( + db.C("name"), + resx.MaskField("Department.budget").As("dept_budget"), + resx.MaskField("Manager.salary").As("mgr_salary"), + ). + Executor().ToSQL() + + t.Contains(sql, "`dept_budget`") + t.NotContains(sql, "-999999999 AS `dept_budget`") + t.Contains(sql, "-999999999 AS `mgr_salary`") +} + +// TestExplicitColumn_FieldNotFoundOnResourceErrors 别名能解析到资源,但资源上没有这个字段 +// (字段名写错了)——报错而不是静默不脱敏 +func (t *maskFieldTest) TestExplicitColumn_FieldNotFoundOnResourceErrors() { + user, dept := t.newJoinedApp() + + _, _, err := user.GetDBTable(testUser{}). + LeftJoin(dept.GetTableExpr().As("Department"), db.On(db.I("User.dept_id").Eq(db.I("Department.id")))). + Select(db.C("name"), resx.MaskField("Department.no_such_field").As("x")). + Executor().ToSQL() + + t.Error(err) +} + +// TestExplicitColumn_UnresolvableAliasErrors 标记的表名在查询里根本不存在(也不是任何资源的 +// code/table/uuid),说明调用方写错了标记——报错而不是静默不脱敏 +func (t *maskFieldTest) TestExplicitColumn_UnresolvableAliasErrors() { + res, _ := t.newResource(nameField, salaryField) + + _, _, err := res.GetDBTable(testUser{}). + Select(db.C("name"), resx.MaskField("NoSuchResource.budget").As("budget")). + Executor().ToSQL() + + t.Error(err) +} + +// TestExplicitColumn_GetResourceUnavailableErrors 容器里根本没注册 req.GetResource(不是"查不到 +// 这个资源",是查找能力本身都拿不到)时,跟其它解析失败的场景(TestExplicitColumn_ +// UnresolvableAliasErrors 等)一样报错,而不是静默脱敏或放行——查不到就是查不到,不能因为查不到 +// 的原因是 DI 而不是资源本身,就换一套更宽松的处理方式 +func (t *maskFieldTest) TestExplicitColumn_GetResourceUnavailableErrors() { + mockDB, _, err := sqlmock.New() + t.Require().NoError(err) + + container := do.New() + do.Provide(container, func(i do.Injector) (engine.Engine, error) { + return engine.Mock(map[string]engine.MockDBConfig{ + "default": {Driver: "mysql", Mock: mockDB}, + }), nil + }) + // 故意不注册 req.GetResource + + res := resx.New(container, "User", "users", + resx.WithConn("default"), + resx.WithFields(nameField), + ) + + _, _, sqlErr := res.GetDBTable(testUser{}). + LeftJoin(db.T("departments").As("Department"), db.On(db.I("User.dept_id").Eq(db.I("Department.id")))). + Select(db.C("name"), resx.MaskField("Department.budget").As("budget")). + Executor().ToSQL() + + t.ErrorContains(sqlErr, "req.GetResource") +} + +// TestExplicitColumn_OwnAliasSkipsGetResourceEntirely MaskField("User.salary")——别名不是空的, +// 而是显式写了当前资源自己的 code(DataListField 这类配置记录里,master/自身资源的字段经常这么 +// 存,不是留空)——这种情况必须直接走 dp.res,完全不碰 req.GetResource,哪怕容器里根本没注册 +// req.GetResource 也要能正确解析:没配置 Roles 的字段原样通过,配置了的字段才脱敏,不能因为 +// "别名非空"就误当成 JOIN 字段一律走 DI、进而在 req.GetResource 不可用时把没配置权限的字段也 +// 一起误脱敏 +func (t *maskFieldTest) TestExplicitColumn_OwnAliasSkipsGetResourceEntirely() { + mockDB, _, err := sqlmock.New() + t.Require().NoError(err) + + container := do.New() + do.Provide(container, func(i do.Injector) (engine.Engine, error) { + return engine.Mock(map[string]engine.MockDBConfig{ + "default": {Driver: "mysql", Mock: mockDB}, + }), nil + }) + // 故意不注册 req.GetResource——如果 resolveLazyField 把 "User" 这个别名误当成需要走 DI 的 + // JOIN 别名,这个测试就会失败(要么报错,要么两个字段全被误脱敏成 NULL) + + res := resx.New(container, "User", "users", + resx.WithConn("default"), + resx.WithFields(nameField, salaryField), + ) + + sql, _, sqlErr := res.GetDBTable(testUser{}). + Select(resx.MaskField("User.name").As("name"), resx.MaskField("User.salary").As("salary")). + Executor().ToSQL() + + t.NoError(sqlErr) + t.Contains(sql, "`name`") + t.NotContains(sql, "NULL AS `name`") + t.Contains(sql, "-999999999 AS `salary`") +} + +// -------- MaskField 嵌在 db.Func(...) 参数里 -------- + +// TestExplicitColumn_MaskFieldInsideFuncArgGetsMasked 顶层列是 db.Func(...),MaskField 标记的是 +// 它其中一个参数(不是顶层列本身):resolveMaskedExpr 得递归进 Args() 才能找到并替换,替换后的 +// mask 值不带 AS(函数参数不能单独起别名),外层函数调用整体保留原来的别名 +func (t *maskFieldTest) TestExplicitColumn_MaskFieldInsideFuncArgGetsMasked() { + res, _ := t.newResource(nameField, salaryField) + + sql, _, _ := res.GetDBTable(testUser{}). + Select(db.Func("CONCAT", resx.MaskField("salary"), db.L("' extra'")).As("computed")). + Executor().ToSQL() + + t.Contains(sql, "CONCAT(-999999999, ' extra') AS `computed`") +} + +// TestExplicitColumn_MaskFieldInsideFuncArgRoleMatchNotMasked 函数参数里的 MaskField 标记,用户 +// 角色命中时跟顶层列一样不脱敏,函数调用整体原样保留 +func (t *maskFieldTest) TestExplicitColumn_MaskFieldInsideFuncArgRoleMatchNotMasked() { + res, _ := t.newResource(nameField, salaryField) + + sql, _, _ := res.GetDBTable(testUser{roles: []string{"hr"}}). + Select(db.Func("CONCAT", resx.MaskField("salary"), db.L("' extra'")).As("computed")). + Executor().ToSQL() + + t.Contains(sql, "CONCAT(`salary`, ' extra') AS `computed`") + t.NotContains(sql, "-999999999") +} + +// TestExplicitColumn_MaskFieldInsideNestedFuncArgGetsMasked MaskField 嵌套两层函数调用 +// (db.Func("UPPER", db.Func("CONCAT", ...)))也能递归找到并替换——resolveMaskedExpr 对 +// SQLFunctionExpression 是递归调用自己,不是只展开一层 +func (t *maskFieldTest) TestExplicitColumn_MaskFieldInsideNestedFuncArgGetsMasked() { + res, _ := t.newResource(nameField, salaryField) + + sql, _, _ := res.GetDBTable(testUser{}). + Select(db.Func("UPPER", db.Func("CONCAT", resx.MaskField("salary"), db.L("' extra'"))).As("computed")). + Executor().ToSQL() + + t.Contains(sql, "UPPER(CONCAT(-999999999, ' extra')) AS `computed`") +} + +// TestExplicitColumn_MaskFieldInsideJoinedFuncArgGetsMasked 函数参数里的 MaskField 也支持带别名 +// 的 JOIN 字段,跟顶层列的解析逻辑(resolveJoinTable 换真实表名)共用同一套 +func (t *maskFieldTest) TestExplicitColumn_MaskFieldInsideJoinedFuncArgGetsMasked() { + user, dept := t.newJoinedApp() + + sql, _, _ := user.GetDBTable(testUser{}). + LeftJoin(dept.GetTableExpr().As("Department"), db.On(db.I("User.dept_id").Eq(db.I("Department.id")))). + Select(db.Func("CONCAT", resx.MaskField("Department.budget"), db.L("' extra'")).As("computed")). + Executor().ToSQL() + + t.Contains(sql, "CONCAT(-999999999, ' extra') AS `computed`") +} + +// TestCustomMaskFunc_Override 容器里注册了 MaskFunc 时,脱敏值用注册的而不是内置默认值 +func (t *maskFieldTest) TestCustomMaskFunc_Override() { + mockDB, _, err := sqlmock.New() + t.Require().NoError(err) + + container := do.New() + do.Provide(container, func(i do.Injector) (engine.Engine, error) { + return engine.Mock(map[string]engine.MockDBConfig{ + "default": {Driver: "mysql", Mock: mockDB}, + }), nil + }) + do.Provide(container, func(i do.Injector) (resx.MaskFunc, error) { + return func(field req.ResField) exp.LiteralExpression { + return db.V("REDACTED") + }, nil + }) + + customRes := resx.New(container, "User", "users", + resx.WithConn("default"), + resx.WithFields(salaryField), + ) + + sql, _, _ := customRes.GetDBTable(testUser{}).Select(db.Star()).Executor().ToSQL() + + t.Contains(sql, "REDACTED") + t.NotContains(sql, "-999999999") +} + +// -------- 写权限:normalizeSaveValue 静默丢弃没有权限的字段 -------- + +// TestInsert_DropsFieldWithoutWriteAccess 没有权限时,INSERT 里这个字段被静默丢弃 +func (t *maskFieldTest) TestInsert_DropsFieldWithoutWriteAccess() { + res, _ := t.newResource(nameField, salaryField) + + sql, _, _ := res.GetDBTable(testUser{}).Insert(). + Rows(db.Record{"name": "张三", "salary": 999999}). + Executor().ToSQL() + + t.Contains(sql, "张三") + t.NotContains(sql, "999999", "没有写权限的字段应该被静默丢弃,不应该出现在 INSERT 里") +} + +// TestInsert_KeepsFieldWithWriteAccess 有权限时,INSERT 正常写入真实值 +func (t *maskFieldTest) TestInsert_KeepsFieldWithWriteAccess() { + res, _ := t.newResource(nameField, salaryField) + + sql, _, _ := res.GetDBTable(testUser{roles: []string{"hr"}}).Insert(). + Rows(db.Record{"name": "张三", "salary": 999999}). + Executor().ToSQL() + + t.Contains(sql, "999999") +} + +// TestUpdate_DropsFieldWithoutWriteAccess UPDATE 场景下同样会被静默丢弃 +func (t *maskFieldTest) TestUpdate_DropsFieldWithoutWriteAccess() { + res, _ := t.newResource(nameField, salaryField) + + sql, _, _ := res.GetDBTable(testUser{}).Update(). + Set(db.Record{"name": "李四", "salary": 888888}). + Where(db.C("id").Eq(1)). + Executor().ToSQL() + + t.Contains(sql, "李四") + t.NotContains(sql, "888888") +} diff --git a/resx/res_normalize_test.go b/resx/res_normalize_test.go new file mode 100644 index 0000000..f732bdb --- /dev/null +++ b/resx/res_normalize_test.go @@ -0,0 +1,234 @@ +package resx_test + +import ( + "strings" + "testing" + + "git.fsdpf.net/go/contracts/base" + "git.fsdpf.net/go/db" + "git.fsdpf.net/go/db/engine" + "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" +) + +// normalizeTest 针对 dataProcessor 的 normalizeSaveValue / applyDefaultValue 两个私有方法做黑盒验证: +// 由于它们只在 INSERT/UPDATE 前被调用,行为都会体现在最终生成的 SQL 上,所以借助 ToSQL() 而不必真正 +// 执行 Exec,也不需要 sqlmock 的 Expect* 断言。 +type normalizeTest struct { + suite.Suite +} + +func TestResourceNormalize(t *testing.T) { + suite.Run(t, new(normalizeTest)) +} + +const anonUUID = "00000000-0000-0000-0000-000000000000" + +// newNormalizeResource 构造一个绑定了 sqlmock 的 User 资源,不设置 onBefore/onAfter: +// normalizeSaveValue/applyDefaultValue 在 INSERT/UPDATE 前无条件执行,不依赖这两个钩子。 +func (t *normalizeTest) newNormalizeResource(fields ...req.ResField) req.Resource { + mockDB, _, 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 + }) + + return resx.New(app, "User", "users", + resx.WithConn("default"), + resx.WithFields(fields...), + ) +} + +// -------- normalizeSaveValue -------- + +// TestStripsSystemFields id/created_at/deleted_at/updated_at 无条件剔除,created_user 被强制改写为当前用户 +func (t *normalizeTest) TestStripsSystemFields() { + res := t.newNormalizeResource( + resx.NewResField("name", "", resx.FieldWithName("姓名"), resx.FieldWithDataType(req.ResString)), + ) + + sql, _, _ := res.GetDBTable(base.GetAnonymous()).Insert().Rows( + db.Record{ + "id": 999, + "name": "张三", + "created_at": "2020-01-01", + "deleted_at": "2020-01-01", + "updated_at": "2020-01-01", + "created_user": "attacker-uuid", + }, + ).Executor().ToSQL() + + t.NotContains(sql, "attacker-uuid") + t.NotContains(sql, "`id`") + t.NotContains(sql, "`created_at`") + t.NotContains(sql, "`deleted_at`") + t.NotContains(sql, "`updated_at`") + t.Contains(sql, "`created_user`") + // created_user、owned_user 都会落到当前用户 uuid 上 + t.GreaterOrEqual(strings.Count(sql, anonUUID), 2) +} + +// TestDropsUndeclaredFields 未在 WithFields 中声明的字段会被静默丢弃 +func (t *normalizeTest) TestDropsUndeclaredFields() { + res := t.newNormalizeResource( + resx.NewResField("name", "", resx.FieldWithName("姓名"), resx.FieldWithDataType(req.ResString)), + ) + + sql, _, _ := res.GetDBTable(base.GetAnonymous()).Insert().Rows( + db.Record{"name": "李四", "hacker_field": "evil"}, + ).Executor().ToSQL() + + t.NotContains(sql, "hacker_field") + t.NotContains(sql, "evil") + t.Contains(sql, "李四") +} + +// TestExpressionValuePassesThroughEvenIfUndeclared db.Expression 类型的值会原样保留, +// 既不会被 ToValue 转换,也不受"未声明字段丢弃"规则约束 +func (t *normalizeTest) TestExpressionValuePassesThroughEvenIfUndeclared() { + res := t.newNormalizeResource( + resx.NewResField("name", "", resx.FieldWithName("姓名"), resx.FieldWithDataType(req.ResString)), + ) + + sql, _, _ := res.GetDBTable(base.GetAnonymous()).Insert().Rows( + db.Record{"name": "王五", "login_at": db.L("NOW()")}, + ).Executor().ToSQL() + + t.Contains(sql, "`login_at`") + t.Contains(sql, "NOW()") +} + +// TestJSONFieldNilUsesFieldDefault 显式传 nil 的 json 字段会被 field.ToValue 换成该字段的默认值 +func (t *normalizeTest) TestJSONFieldNilUsesFieldDefault() { + res := t.newNormalizeResource( + resx.NewResField("name", "", resx.FieldWithName("姓名"), resx.FieldWithDataType(req.ResString)), + resx.NewResField("fields", "", resx.FieldWithName("字段"), resx.FieldWithDefault("[]"), resx.FieldWithDataType(req.ResJson)), + ) + + sql, _, _ := res.GetDBTable(base.GetAnonymous()).Insert().Rows( + db.Record{"name": "A", "fields": nil}, + ).Executor().ToSQL() + + t.Contains(sql, "`fields`") + t.Contains(sql, "'[]'") +} + +// -------- applyDefaultValue -------- + +// TestJSONFieldDefaultFilledWhenOmitted 完全不传 json 字段时,applyDefaultValue 会按字段默认值补全 +func (t *normalizeTest) TestJSONFieldDefaultFilledWhenOmitted() { + res := t.newNormalizeResource( + resx.NewResField("name", "", resx.FieldWithName("姓名"), resx.FieldWithDataType(req.ResString)), + resx.NewResField("fields", "", resx.FieldWithName("字段"), resx.FieldWithDefault("{}"), resx.FieldWithDataType(req.ResJson)), + ) + + sql, _, _ := res.GetDBTable(base.GetAnonymous()).Insert().Rows( + db.Record{"name": "B"}, + ).Executor().ToSQL() + + t.Contains(sql, "`fields`") + t.Contains(sql, "'{}'") +} + +// TestNonJSONFieldDefaultNotAutoFilled 非 json 类型字段的 Default 不会被 applyDefaultValue 自动补全 +// (getFieldsDefaultValue 只处理 json 字段和 sql: 前缀的默认值),完全依赖数据库表结构自身的默认值 +func (t *normalizeTest) TestNonJSONFieldDefaultNotAutoFilled() { + res := t.newNormalizeResource( + resx.NewResField("name", "", resx.FieldWithName("姓名"), resx.FieldWithDefault("匿名"), resx.FieldWithDataType(req.ResString)), + ) + + sql, _, _ := res.GetDBTable(base.GetAnonymous()).Insert().Rows( + db.Record{}, + ).Executor().ToSQL() + + t.NotContains(sql, "`name`") + t.NotContains(sql, "匿名") +} + +// TestOwnedUserDefaultsToCurrentUserWhenUndeclared owned_user 未声明为资源字段时, +// 调用方传入的值会被 normalizeSaveValue 当作未知字段丢弃,再由 applyDefaultValue 强制填充为当前用户 +func (t *normalizeTest) TestOwnedUserDefaultsToCurrentUserWhenUndeclared() { + res := t.newNormalizeResource( + resx.NewResField("name", "", resx.FieldWithName("姓名"), resx.FieldWithDataType(req.ResString)), + ) + + sql, _, _ := res.GetDBTable(base.GetAnonymous()).Insert().Rows( + db.Record{"name": "C", "owned_user": "someone-else-uuid"}, + ).Executor().ToSQL() + + t.NotContains(sql, "someone-else-uuid") + t.Contains(sql, "`owned_user`") + t.Contains(sql, anonUUID) +} + +// TestOwnedUserPreservedWhenDeclaredAsField 一旦把 owned_user 声明成资源字段, +// 调用方传入的值会被 field.ToValue 保留,applyDefaultValue 见 row 里已有值就不会覆盖 +func (t *normalizeTest) TestOwnedUserPreservedWhenDeclaredAsField() { + res := t.newNormalizeResource( + resx.NewResField("name", "", resx.FieldWithName("姓名"), resx.FieldWithDataType(req.ResString)), + resx.NewResField("owned_user", "", resx.FieldWithName("拥有者"), resx.FieldWithDataType(req.ResString)), + ) + + sql, _, _ := res.GetDBTable(base.GetAnonymous()).Insert().Rows( + db.Record{"name": "D", "owned_user": "someone-else-uuid"}, + ).Executor().ToSQL() + + t.Contains(sql, "someone-else-uuid") +} + +// TestCreatedUserAlwaysForcedToCurrentUser created_user 属于系统字段,无论是否声明为资源字段, +// 都会被 normalizeSaveValue 无条件剔除,再由 applyDefaultValue 强制写入当前用户 +func (t *normalizeTest) TestCreatedUserAlwaysForcedToCurrentUser() { + res := t.newNormalizeResource( + resx.NewResField("name", "", resx.FieldWithName("姓名"), resx.FieldWithDataType(req.ResString)), + resx.NewResField("created_user", "", resx.FieldWithName("创建者"), resx.FieldWithDataType(req.ResString)), + ) + + sql, _, _ := res.GetDBTable(base.GetAnonymous()).Insert().Rows( + db.Record{"name": "E", "created_user": "someone-else-uuid"}, + ).Executor().ToSQL() + + t.NotContains(sql, "someone-else-uuid") + t.Contains(sql, "`created_user`") + t.Contains(sql, anonUUID) +} + +// -------- UPDATE 场景下的 normalizeSaveValue -------- + +// TestNormalizeSaveValue_Update Set() 传入的数据同样会经过 normalizeSaveValue: +// 剔除系统字段、丢弃未声明字段、对声明字段做 ToValue 转换 +func (t *normalizeTest) TestNormalizeSaveValue_Update() { + res := t.newNormalizeResource( + resx.NewResField("name", "", resx.FieldWithName("姓名"), resx.FieldWithDataType(req.ResString)), + resx.NewResField("fields", "", resx.FieldWithName("字段"), resx.FieldWithDefault("{}"), resx.FieldWithDataType(req.ResJson)), + ) + + sql, _, _ := res.GetDBTable(base.GetAnonymous()).Update(). + Set(db.Record{ + "id": 1, + "name": "F", + "fields": map[string]any{"x": 1}, + "hacker": "y", + "updated_at": "ignored", + }). + Where(db.C("id").Eq(1)). + Executor().ToSQL() + + t.NotContains(sql, "`hacker`") + t.NotContains(sql, "ignored") + t.NotContains(sql, "`updated_at`=") + t.NotContains(sql, "`id`=") + t.Contains(sql, "`name`='F'") + t.Contains(sql, "`fields`='{\\\"x\\\":1}'") + // WHERE 条件里的 id 不受影响 + t.Contains(sql, "WHERE (`id` = 1)") +} diff --git a/resx/res_query_field.go b/resx/res_query_field.go new file mode 100644 index 0000000..b1a2a7d --- /dev/null +++ b/resx/res_query_field.go @@ -0,0 +1,168 @@ +package resx + +import ( + "reflect" + "strings" + "unicode" + + "git.fsdpf.net/go/db" + "git.fsdpf.net/go/db/exp" + "git.fsdpf.net/go/req" +) + +// QueryField 是 req.QueryField 的具体实现。原来放在 orm-v2,这里搬过来跟 ResField 是同一个原因: +// ToSql() 需要用 MaskField 按字段的 Roles 做脱敏标记,放在 resx 里可以直接调用,不用 orm 包再反过来 +// import resx。orm-v2/contracts-v2 base 的 QueryField 现在都是指向这里的类型别名,兼容原有引用。 +// +// 嵌入的是 req.ResField 接口而不是具体的 ResField 结构体:调用方经常手上已经有一个真实的 +// req.ResField(比如从 resource.GetField(code) 查出来的),这时候直接传它本身即可,不用先转换成 +// 具体类型(也不用为了绕开"没有的字段"再拼一个大部分为空的 ResField 出来)。QueryField 自己的方法 +// 全程只通过 req.ResField 接口访问底层字段,不需要具体类型独有的能力。 +type QueryField struct { + req.ResField + typ req.RouteParamType `db:"dataType"` + alias string `db:"alias"` + isExpr bool `db:"isExpr"` + isOmitempty bool + ignored bool +} + +// Alias 返回字段的别名。 +func (this QueryField) Alias() string { + return this.alias +} + +// Ignored 返回字段是否被忽略。 +func (this QueryField) Ignored() bool { + return this.ignored +} + +// IsExpr 返回字段是否为表达式。 +func (this QueryField) IsExpr() bool { + return this.isExpr +} + +// IsOmitempty 返回字段是否在为空时忽略。 +func (this QueryField) IsOmitempty() bool { + return this.isOmitempty +} + +// Type 返回字段的数据类型。 +func (this QueryField) Type() req.RouteParamType { + return this.typ +} + +// SetOptions 设置字段选项(表达式、忽略空值、忽略等)。 +func (this *QueryField) SetOptions(options byte) req.QueryField { + if options&req.IsExpr != 0 { + this.isExpr = true + } + + if options&req.IsOmitempty != 0 { + this.isOmitempty = true + } + + if options&req.Ignored != 0 { + this.ignored = true + } + + return this +} + +// GetCodeOrAlias 返回字段的别名(如果有),否则返回字段代码。 +func (this QueryField) GetCodeOrAlias() string { + if this.Alias() != "" { + return this.Alias() + } + + return this.GetCode() +} + +// ToSql 将查询字段转换为SQL表达式。 +func (this *QueryField) ToSql() db.Expression { + if this.Alias() != "" { + if this.IsExpr() { + if this.GetCode() == "" { + return db.V(nil).As(this.Alias()) + } + return db.L("(?)", db.L(this.GetCode())).As(this.Alias()) + } + return this.col().As(this.Alias()) + } + return this.col() +} + +// col 构造这个字段对应的列引用,一律用 MaskField(...) 标记。这里不能靠 this.GetRoles() 提前判断 +// 要不要标记——构造 QueryField 的这个 ResField 经常是从 DataListField/ResApiField 之类的配置记录 +// 现造出来的,根本不带 Roles(这些配置结构体压根没有 Roles 这一列),如果在这里用"当前手上这个 +// ResField 有没有 Roles"来决定要不要包 MaskField,等于默认永远不脱敏,绕过了字段级权限。 +// 真正的判断交给 dataProcessor.maskSelect:查询真正执行前,从 SELECT 列表所在的 FROM/JOIN 子句里 +// 把 GetCodeResource() 这个别名解析成真实表名,再查真实资源上对应字段的真实 Roles——这一步不需要 +// 构造 QueryField 时就知道 Roles。 +func (this *QueryField) col() exp.IdentifierExpression { + return MaskField(this.GetCodeResource() + "." + this.GetCode()) +} + +// ToStructField 将查询字段转换为反射结构体字段定义。 +func (this *QueryField) ToStructField(tags ...string) reflect.StructField { + var typ reflect.Type + + fCode := this.GetCodeOrAlias() + + // 字段规则 + if !unicode.IsLetter(rune(fCode[0])) { + panic("struct field name invalid. " + fCode) + } + + fTag := `db:"` + fCode + + if this.ignored { + fTag = fTag + `" json:"-"` + } else if this.isOmitempty { + fTag = fTag + `" json:"` + fCode + `,omitempty"` + } else { + fTag = fTag + `" json:"` + fCode + `"` + } + + if len(tags) > 0 { + fTag = strings.Join(tags, " ") + " " + fTag + } + + switch this.typ { + case req.ReqString: + typ = reflect.TypeOf("") + case req.ReqNumber: + typ = reflect.TypeOf(float64(0)) + case req.ReqInteger: + typ = reflect.TypeOf(int64(0)) + case req.ReqFloat: + typ = reflect.TypeOf(float64(0)) + case req.ReqBool: + typ = reflect.TypeOf(true) + case req.ReqArray: + typ = reflect.TypeOf([]any{}) + case req.ReqJson: + typ = reflect.TypeOf(map[string]any{}) + } + + return reflect.StructField{ + Name: strings.ToUpper(fCode[:1]) + fCode[1:], + Tag: reflect.StructTag(fTag), + Type: typ, + } +} + +// NewQueryField 创建并返回一个新的查询字段实例。 +// 参数 rField 是资源字段,t 是字段类型,opts 是可选的配置选项。 +func NewQueryField(rField req.ResField, t req.RouteParamType, opts ...QueryFieldOption) req.QueryField { + qf := &QueryField{ + ResField: rField, + typ: t, + } + + for _, opt := range opts { + opt(qf) + } + + return qf +} diff --git a/resx/res_query_field_option.go b/resx/res_query_field_option.go new file mode 100644 index 0000000..17bbd54 --- /dev/null +++ b/resx/res_query_field_option.go @@ -0,0 +1,32 @@ +package resx + +// QueryFieldOption 定义查询字段的选项函数类型。 +type QueryFieldOption func(*QueryField) + +// WithAlias 设置查询字段的别名。 +func WithAlias(alias string) QueryFieldOption { + return func(qf *QueryField) { + qf.alias = alias + } +} + +// WithIsExpr 设置查询字段是否为表达式。 +func WithIsExpr(isExpr bool) QueryFieldOption { + return func(qf *QueryField) { + qf.isExpr = isExpr + } +} + +// WithOmitEmpty 设置查询字段是否在为空时忽略。 +func WithOmitEmpty(isOmitempty bool) QueryFieldOption { + return func(qf *QueryField) { + qf.isOmitempty = isOmitempty + } +} + +// WithIgnore 设置查询字段是否被忽略。 +func WithIgnore(ignored bool) QueryFieldOption { + return func(qf *QueryField) { + qf.ignored = ignored + } +} diff --git a/resx/resource_test.go b/resx/resource_test.go new file mode 100644 index 0000000..40f6822 --- /dev/null +++ b/resx/resource_test.go @@ -0,0 +1,88 @@ +package resx_test + +import ( + "testing" + + "git.fsdpf.net/go/contracts/base" + "git.fsdpf.net/go/db" + "git.fsdpf.net/go/db/engine" + "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" +) + +type resourceTest struct { + suite.Suite + res req.Resource +} + +func TestResource(t *testing.T) { + suite.Run(t, new(resourceTest)) +} + +func (t *resourceTest) SetupSuite() { + mockDB, _, err := sqlmock.New() + t.Require().NoError(err) + + app := do.New() + + do.Provide(app, func(container do.Injector) (engine.Engine, error) { + return engine.Mock(map[string]engine.MockDBConfig{ + "default": {Driver: "mysql", Mock: mockDB}, + }), nil + }) + + t.res = resx.New(app, "User", "users", + resx.WithUuid("4bf3a311-cbe1-4236-bdda-c64ab04ae9b1"), + resx.WithName("用户"), + resx.WithConn("default"), + resx.WithFields( + resx.NewResField("name", "User", resx.FieldWithName("姓名"), resx.FieldWithDataType(req.ResString)), + resx.NewResField("fields", "User", resx.FieldWithName("字段"), resx.FieldWithDefault("{}"), resx.FieldWithDataType(req.ResJson)), + ), + ) +} + +func (t *resourceTest) TestInsertRows() { + type Temp struct { + Name string `db:"name"` + Fields map[string]any `db:"fields"` + } + + result := "INSERT INTO `users` (`created_user`, `fields`, `name`, `owned_user`) " + + "VALUES ('00000000-0000-0000-0000-000000000000', '{\\\"aa\\\":1}', '张三', '00000000-0000-0000-0000-000000000000')" + + sql, _, _ := t.res.GetDBTable(base.GetAnonymous()).Insert().Rows( + db.Record{"name": "张三", "id": 1, "fields": map[string]any{"aa": 1}}, + ).Executor().ToSQL() + t.Equal(result, sql) + + // 注意:db-v2 的 exp.NewRecordFromStruct 传入指针 struct 时会 panic + // (util.SafeGetFieldByIndex 未对指针做 reflect.Indirect),此处暂不测试指针形式 + + sql, _, _ = t.res.GetDBTable(base.GetAnonymous()).Insert().Rows( + Temp{Name: "张三", Fields: map[string]any{"aa": 1}}, + ).Executor().ToSQL() + t.Equal(result, sql) +} +func (t *resourceTest) TestInsertColsVals() { + result := "INSERT INTO `users` (`name`, `fields`, `created_user`, `owned_user`) VALUES " + + "('李四', '{\\\"aa\\\":1}', '00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000000'), " + + "('王五', '{\\\"aa\\\":3}', '00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000000')" + + sql, _, _ := t.res.GetDBTable(base.GetAnonymous()).Insert().Cols("name", "fields").Vals( + db.Vals{"李四", map[string]any{"aa": 1}}, + db.Vals{"王五", map[string]any{"aa": 3}}, + ).Executor().ToSQL() + + _ = result + _ = sql + + // t.Equal(result, sql) + +} +func (*resourceTest) TestInsertColsFromQuery() {}