diff --git a/base/grid_layout.go b/base/grid_layout.go index 41e76e5..920202d 100644 --- a/base/grid_layout.go +++ b/base/grid_layout.go @@ -1,25 +1,25 @@ package base type GridLayout struct { - Uuid string `db:"uuid"` - ResourceUuid string `db:"resource_uuid"` // 所属资源UUID - Code string `db:"code"` // 布局标识 - Name string `db:"name"` // 布局名称 - Type int `db:"type"` // 布局类型 [0=普通布局, 1=辅助布局] - PrimaryKey string `db:"primaryKey"` // 主键 - MarginX int `db:"marginX"` // 栅格 x 间距 - MarginY int `db:"marginY"` // 栅格 y 间距 - Cols int `db:"cols"` // 网格列基数 - RowHeight float32 `db:"rowHeight"` // 栅格行高 - Css map[string]any `db:"css"` // 布局CSS - Props []any `db:"props"` // 布局PROPS - Groups []map[string]any `db:"groups"` // 布局分组 - Theme string `db:"theme"` // 布局样式 - ThemeConfig map[string]any `db:"themeConfig"` // 布局样式配置 - Platform string `db:"platform"` - Roles []string `db:"roles"` - UpdatedAt string `db:"updated_at"` - CreatedAt string `db:"created_at"` + Uuid string `db:"uuid"` + ResourceUuid string `db:"resource_uuid"` // 所属资源UUID + Code string `db:"code"` // 布局标识 + Name string `db:"name"` // 布局名称 + Type int `db:"type"` // 布局类型 [0=普通布局, 1=辅助布局] + PrimaryKey string `db:"primaryKey"` // 主键 + ItemMargin []any `db:"itemMargin"` // 栅格间距 + ContainerPadding []any `db:"containerPadding"` // 容器内边距 + Cols int `db:"cols"` // 网格列基数 + RowHeight float32 `db:"rowHeight"` // 栅格行高 + Css map[string]any `db:"css"` // 布局CSS + Props []any `db:"props"` // 布局PROPS + Groups []map[string]any `db:"groups"` // 布局分组 + Theme string `db:"theme"` // 布局样式 + ThemeConfig map[string]any `db:"themeConfig"` // 布局样式配置 + Platform string `db:"platform"` + Roles []string `db:"roles"` + UpdatedAt string `db:"updated_at"` + CreatedAt string `db:"created_at"` } type GetGridLayout func(code string) (GridLayout, bool) diff --git a/base/query_field.go b/base/query_field.go deleted file mode 100644 index e849787..0000000 --- a/base/query_field.go +++ /dev/null @@ -1,124 +0,0 @@ -package base - -import ( - "reflect" - "strings" - "unicode" - - "git.fsdpf.net/go/db" - "git.fsdpf.net/go/req" -) - -type QueryField struct { - ResField - typ req.RouteParamType `db:"dataType"` - alias string `db:"alias"` - isExpr bool `db:"isExpr"` - isOmitempty bool - ignored bool -} - -func (this QueryField) Alias() string { - return this.alias -} - -func (this QueryField) Ignored() bool { - return this.ignored -} - -func (this QueryField) IsExpr() bool { - return this.isExpr -} - -func (this QueryField) IsOmitempty() bool { - return this.isOmitempty -} - -func (this QueryField) Type() req.RouteParamType { - return this.typ -} - -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 -} - -func (this QueryField) GetCodeOrAlias() string { - if this.Alias() != "" { - return this.Alias() - } - - return this.Code -} - -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(this.GetCode()).As(this.Alias()) - } - return db.T(this.GetCodeResource()).Col(this.GetCode()).As(this.Alias()) - } - return db.T(this.GetCodeResource()).Col(this.GetCode()) -} - -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(string("")) - 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, - } -} diff --git a/base/res_api.go b/base/res_api.go index 3496205..a3bde03 100644 --- a/base/res_api.go +++ b/base/res_api.go @@ -1,22 +1,17 @@ package base -type ResApiParam struct { - Code string `db:"code" json:"code"` - Name string `db:"name" json:"name"` - Category string `db:"category" json:"category"` - DataType string `db:"type" json:"type"` - IsRequired bool `db:"isRequired" json:"isRequired"` -} - type ResApi struct { Uuid string `db:"uuid"` + PUuid string `db:"pUuid"` Code string `db:"code"` UriPrefix string `db:"uri_prefix"` Name string `db:"name"` + Desc string `db:"desc"` PrimaryKey string `db:"primaryKey"` ResourceUuid string `db:"resource_uuid"` Method string `db:"method"` Action string `db:"action"` + Version string `db:"version"` Params []ResApiParam `db:"params"` Roles []string `db:"roles"` IsAuthDB int `db:"is_auth_db"` // 启用权限过滤 @@ -25,3 +20,5 @@ type ResApi struct { } type GetResApi func(code string) (ResApi, bool) + +type GetResApiChilds func(code string) []ResApi diff --git a/base/res_api_param.go b/base/res_api_param.go new file mode 100644 index 0000000..3b7d22d --- /dev/null +++ b/base/res_api_param.go @@ -0,0 +1,423 @@ +package base + +import ( + "encoding/json" + "sort" + "strconv" + + jschema "github.com/google/jsonschema-go/jsonschema" + "github.com/spf13/cast" +) + +type ResApiParam struct { + ID int64 `db:"id" json:"id"` + PID int64 `db:"pid" json:"pid"` + Code string `db:"code" json:"code"` + Name string `db:"name" json:"name"` + Category string `db:"category" json:"category"` + DataType string `db:"type" json:"type"` + Desc string `db:"desc" json:"desc"` + IsRequired bool `db:"isRequired" json:"isRequired"` + Constraints map[string]any `db:"constraints" json:"constraints"` + Rank int `db:"rank" json:"rank"` + RefID int64 `db:"ref_id" json:"ref_id"` + DefaultVal string `db:"df_val" json:"df_val"` +} + +type ResApiParams []ResApiParam + +// ToJSONSchema 将扁平参数列表还原为 JSON Schema 对象 +func (params ResApiParams) ToJSONSchema() *jschema.Schema { + return params.buildObject(0) +} + +// ResApiParamsFromJSONSchema 将 JSON Schema 对象解析为扁平参数列表 +func ResApiParamsFromJSONSchema(schema *jschema.Schema) ResApiParams { + var params ResApiParams + var seq int64 + params.parseObject(schema, 0, &seq) + return params +} + +func (params ResApiParams) childrenOf(pid int64) ResApiParams { + var result ResApiParams + for _, p := range params { + if p.PID == pid { + result = append(result, p) + } + } + sort.Slice(result, func(i, j int) bool { + return result[i].Rank < result[j].Rank + }) + return result +} + +func (params ResApiParams) buildObject(pid int64) *jschema.Schema { + s := &jschema.Schema{Type: "object"} + props := map[string]*jschema.Schema{} + + for _, p := range params.childrenOf(pid) { + if p.Code == "items" || p.Code == "oneOf" { + continue + } + props[p.Code] = params.buildProperty(p) + if p.IsRequired { + s.Required = append(s.Required, p.Code) + } + } + if len(props) > 0 { + s.Properties = props + } + sort.Strings(s.Required) + return s +} + +func (params ResApiParams) findByID(id int64) (ResApiParam, bool) { + for _, p := range params { + if p.ID == id { + return p, true + } + } + return ResApiParam{}, false +} + +// pathOf 计算参数在生成的 JSON Schema 中的路径 +func (params ResApiParams) pathOf(id int64) string { + p, ok := params.findByID(id) + if !ok { + return "" + } + if p.PID == 0 { + return "#/properties/" + p.Code + } + parent := params.pathOf(p.PID) + switch p.Code { + case "items": + return parent + "/items" + case "oneOf": + return parent + "/items/oneOf/" + strconv.Itoa(p.Rank) + default: + return parent + "/properties/" + p.Code + } +} + +func (params ResApiParams) buildProperty(p ResApiParam) *jschema.Schema { + if p.RefID != 0 { + if _, ok := params.findByID(p.RefID); ok { + return &jschema.Schema{Ref: params.pathOf(p.RefID), Description: p.Desc} + } + } + + // any 类型:不设 type,从 oneOf 子节点构建 + if p.DataType == "any" { + s := &jschema.Schema{Title: p.Name, Description: p.Desc} + if p.DefaultVal != "" { + var tmp any + if err := json.Unmarshal([]byte(p.DefaultVal), &tmp); err == nil { + s.Default = json.RawMessage(p.DefaultVal) + } else { + s.Default, _ = json.Marshal(p.DefaultVal) + } + } + for _, child := range params.childrenOf(p.ID) { + c := child + if child.Code == "oneOf" { + s.OneOf = append(s.OneOf, params.buildProperty(c)) + } + } + params.applyConstraints(s, p.Constraints) + return s + } + + // 归一化 DataType:json → object;空字符串根据子节点推断 + dataType := p.DataType + switch dataType { + case "json": + dataType = "object" + case "bool": + dataType = "boolean" + case "": + for _, c := range params.childrenOf(p.ID) { + if c.Code == "oneOf" || c.Code == "items" { + dataType = "array" + break + } + } + if dataType == "" && len(params.childrenOf(p.ID)) > 0 { + dataType = "object" + } + } + + s := &jschema.Schema{ + Type: dataType, + Title: p.Name, + Description: p.Desc, + } + + if p.DefaultVal != "" { + var tmp any + if err := json.Unmarshal([]byte(p.DefaultVal), &tmp); err == nil { + s.Default = json.RawMessage(p.DefaultVal) + } else { + s.Default, _ = json.Marshal(p.DefaultVal) + } + } + + params.applyConstraints(s, p.Constraints) + + switch dataType { + case "object": + nested := params.buildObject(p.ID) + s.Properties = nested.Properties + s.Required = nested.Required + case "array": + var oneOfSchemas []*jschema.Schema + var itemsChild *ResApiParam + for _, child := range params.childrenOf(p.ID) { + c := child + switch child.Code { + case "oneOf": + oneOfSchemas = append(oneOfSchemas, params.buildProperty(c)) + case "items": + itemsChild = &c + } + } + if len(oneOfSchemas) > 0 { + s.Items = &jschema.Schema{OneOf: oneOfSchemas} + } else if itemsChild != nil { + s.Items = params.buildItems(*itemsChild) + } + } + + return s +} + +func (params ResApiParams) buildItems(p ResApiParam) *jschema.Schema { + if ref, ok := p.Constraints["$ref"].(string); ok { + return &jschema.Schema{Ref: ref} + } + if p.RefID != 0 { + if ref, ok := params.findByID(p.RefID); ok { + path := params.pathOf(p.RefID) + if ref.DataType == "array" { + path += "/items" + } + return &jschema.Schema{Ref: path} + } + } + itemType := p.DataType + if itemType == "json" { + itemType = "object" + } else if itemType == "" && len(params.childrenOf(p.ID)) > 0 { + itemType = "object" + } + + s := &jschema.Schema{ + Type: itemType, + Title: p.Name, + Description: p.Desc, + } + + if s.Description == "" && s.Title != "" { + s.Description = s.Title + } + + if itemType == "object" { + nested := params.buildObject(p.ID) + s.Properties = nested.Properties + s.Required = nested.Required + } + params.applyConstraints(s, p.Constraints) + return s +} + +func (params ResApiParams) applyConstraints(s *jschema.Schema, c map[string]any) { + for k, v := range c { + switch k { + case "$ref": + if ref, ok := v.(string); ok { + s.Ref = ref + } + case "enum": + if arr, ok := v.([]any); ok { + s.Enum = arr + } + case "format": + if f, ok := v.(string); ok { + s.Format = f + } + case "pattern": + if p, ok := v.(string); ok { + s.Pattern = p + } + case "minLength": + s.MinLength = jschema.Ptr(cast.ToInt(v)) + case "maxLength": + s.MaxLength = jschema.Ptr(cast.ToInt(v)) + case "minimum": + s.Minimum = jschema.Ptr(cast.ToFloat64(v)) + case "maximum": + s.Maximum = jschema.Ptr(cast.ToFloat64(v)) + case "multipleOf": + s.MultipleOf = jschema.Ptr(cast.ToFloat64(v)) + case "minItems": + s.MinItems = jschema.Ptr(cast.ToInt(v)) + case "maxItems": + s.MaxItems = jschema.Ptr(cast.ToInt(v)) + case "minProperties": + s.MinProperties = jschema.Ptr(cast.ToInt(v)) + case "maxProperties": + s.MaxProperties = jschema.Ptr(cast.ToInt(v)) + case "additionalProperties": + switch val := v.(type) { + case bool: + if val { + s.AdditionalProperties = &jschema.Schema{} + } else { + s.AdditionalProperties = &jschema.Schema{Not: &jschema.Schema{}} + } + } + } + } +} + +func (params *ResApiParams) parseObject(schema *jschema.Schema, pid int64, seq *int64) { + requiredSet := make(map[string]bool, len(schema.Required)) + for _, r := range schema.Required { + requiredSet[r] = true + } + + rank := 0 + for code, prop := range schema.Properties { + *seq++ + id := *seq + + p := ResApiParam{ + ID: id, + PID: pid, + Code: code, + DataType: prop.Type, + Name: prop.Title, + Desc: prop.Description, + IsRequired: requiredSet[code], + DefaultVal: params.rawToStr(prop.Default), + Rank: rank, + Constraints: params.extractConstraints(prop), + } + *params = append(*params, p) + rank++ + + switch prop.Type { + case "object": + params.parseObject(prop, id, seq) + case "array": + if prop.Items == nil { + break + } + // items 为纯 oneOf schema:建立 oneOf 子行,不建 items 子行 + if len(prop.Items.OneOf) > 0 { + for oneOfRank, sub := range prop.Items.OneOf { + *seq++ + oneOfID := *seq + itemType := sub.Type + if itemType == "" { + itemType = "object" + } + *params = append(*params, ResApiParam{ + ID: oneOfID, + PID: id, + Code: "oneOf", + DataType: itemType, + Name: sub.Title, + Desc: sub.Description, + Rank: oneOfRank, + Constraints: params.extractConstraints(sub), + }) + if itemType == "object" { + params.parseObject(sub, oneOfID, seq) + } + } + break + } + *seq++ + itemID := *seq + itemType := prop.Items.Type + if itemType == "" { + itemType = "object" + } + *params = append(*params, ResApiParam{ + ID: itemID, + PID: id, + Code: "items", + DataType: itemType, + Name: prop.Items.Title, + Desc: prop.Items.Description, + Constraints: params.extractConstraints(prop.Items), + }) + if itemType == "object" { + params.parseObject(prop.Items, itemID, seq) + } + } + } +} + +func (params ResApiParams) extractConstraints(s *jschema.Schema) map[string]any { + c := map[string]any{} + if s.Ref != "" { + c["$ref"] = s.Ref + } + if len(s.Enum) > 0 { + c["enum"] = s.Enum + } + if s.Format != "" { + c["format"] = s.Format + } + if s.Pattern != "" { + c["pattern"] = s.Pattern + } + if s.MinLength != nil { + c["minLength"] = *s.MinLength + } + if s.MaxLength != nil { + c["maxLength"] = *s.MaxLength + } + if s.Minimum != nil { + c["minimum"] = *s.Minimum + } + if s.Maximum != nil { + c["maximum"] = *s.Maximum + } + if s.MultipleOf != nil { + c["multipleOf"] = *s.MultipleOf + } + if s.MinItems != nil { + c["minItems"] = *s.MinItems + } + if s.MaxItems != nil { + c["maxItems"] = *s.MaxItems + } + if s.MinProperties != nil { + c["minProperties"] = *s.MinProperties + } + if s.MaxProperties != nil { + c["maxProperties"] = *s.MaxProperties + } + if s.AdditionalProperties != nil { + c["additionalProperties"] = s.AdditionalProperties.Not == nil + } + if len(c) == 0 { + return nil + } + return c +} + +func (params ResApiParams) rawToStr(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var s string + if err := json.Unmarshal(raw, &s); err == nil { + return s + } + return string(raw) +} diff --git a/base/res_api_param_test.go b/base/res_api_param_test.go new file mode 100644 index 0000000..fd40ac2 --- /dev/null +++ b/base/res_api_param_test.go @@ -0,0 +1,446 @@ +package base_test + +import ( + "encoding/json" + "reflect" + "testing" + + "git.fsdpf.net/go/contracts/base" + jschema "github.com/google/jsonschema-go/jsonschema" +) + +// queryParams 查询接口的扁平参数列表,包含 conditions(过滤条件)和 select(查询字段) +var queryParams = base.ResApiParams{ + // resource:目标资源 + { + ID: -1, PID: 0, Code: "resource", DataType: "string", Name: "资源", Rank: 0, + Desc: "查询目标资源编码(resource_code)", + }, + { + ID: 1, PID: 0, Code: "conditions", DataType: "object", Name: "过滤条件", Rank: 1, + Desc: `WHERE 条件树。 +exprs 每项生成一段比较:[{columnSqlFunc}(]{columnResource}.{column}[)] {operator} {value},同层多项以 type(and/or) 连接; +children 为同结构子节点,递归嵌套实现复杂过滤。`, + }, + { + ID: 2, PID: 1, Code: "type", DataType: "string", Name: "逻辑运算符", Rank: 0, + Desc: "当前节点的逻辑运算符,将 exprs 各项与 children 各组以 AND 或 OR 连接", + DefaultVal: "and", + Constraints: map[string]any{"enum": []any{"and", "or"}}, + }, + { + ID: 3, PID: 1, Code: "exprs", DataType: "array", Name: "条件表达式列表", Rank: 1, + Desc: "当前层的叶子比较列表,每项生成一个 column {operator} value 片段,同层多项以 type 连接", + }, + {ID: 4, PID: 3, Code: "items", DataType: "object", Rank: 0}, + { + ID: 5, PID: 4, Code: "column", DataType: "string", Name: "字段", Rank: 0, + Desc: "字段编码", IsRequired: true, + }, + { + ID: 6, PID: 4, Code: "columnResource", DataType: "string", Name: "字段资源", Rank: 1, + Desc: "字段所属资源标识,取值为顶层 resource 或 relations[].code", + }, + { + ID: 7, PID: 4, Code: "operator", DataType: "string", Name: "运算符", Rank: 2, + Desc: `标准 SQL 比较运算符: += / != / > / >= / < / <= +LIKE:模糊匹配(%keyword%) +IN:包含,value 逗号分隔多值 +IS NULL / IS NOT NULL:空值判断,无需 value +REGEXP:正则匹配`, + IsRequired: true, + DefaultVal: "=", + }, + { + ID: 8, PID: 4, Code: "value", DataType: "string", Name: "比较值", Rank: 3, + Desc: "比较目标,内容由 valueType 决定。operator 为 IS NULL/IS NOT NULL 时可省略", + }, + { + ID: 9, PID: 4, Code: "valueType", DataType: "string", Name: "值类型", Rank: 4, + Desc: `先选类型再填 value: +string:value 填字面量如 '2024-01-01' +func:value 填内置函数名 UserID|UserUuid|UserRolesUuid|UserPlatform|UserSaaS +param:value 填请求参数路径如 body.status +sql:value 填原始SQL如 CURRENT_DATE +默认 string`, + Constraints: map[string]any{"enum": []any{"string", "func", "sql", "param"}}, + }, + { + ID: 10, PID: 4, Code: "columnSqlFunc", DataType: "string", Name: "列SQL函数", Rank: 5, + Desc: `对列应用 SQL 函数后再参与比较,拼接为 {func}({columnResource}.{column}) {operator} {value}。 +支持 SQL-92 / SQL:1999 / SQL:2003 标准函数及数据库方言函数,如 DATE、UPPER、ROUND。 +需要第二参数的函数(如 ROUND、CAST)配合 columnSqlFuncParam 使用。`, + }, + { + ID: 13, PID: 4, Code: "columnSqlFuncParam", DataType: "string", Name: "SQL函数参数", Rank: 6, + Desc: `columnSqlFunc 的附加参数,按函数类型用途不同: + +通用函数(有 param 时):{func}({columnResource}.{column}, {columnSqlFuncParam}) {operator} {value} + 示例:ROUND(col, 2) → columnSqlFuncParam="2" + SUBSTRING(col, 1, 10) → columnSqlFuncParam="1, 10" + +JSON 函数(param 为 JSON 路径表达式): + json_member_of(value 是否是列 JSON 数组的成员): + 无 param:JSON_CONTAINS({value}, JSON_ARRAY({column})) + 有 param:JSON_CONTAINS({columnSqlFuncParam}, JSON_ARRAY({column})) + json_contains(列 JSON 是否包含 value): + 无 param:JSON_CONTAINS({column}, JSON_ARRAY({value})) + 有 param:JSON_CONTAINS({columnSqlFuncParam}, JSON_ARRAY({value}))`, + }, + { + ID: 14, PID: 4, Code: "ignoreEmptyParam", DataType: "boolean", Name: "忽略空参数", Rank: 7, + Desc: "当 valueType=param 且请求参数值为空(空字符串或 null)时,跳过该条件不生成 SQL。用于实现可选过滤项,默认 false", + }, + { + ID: 11, PID: 1, Code: "children", DataType: "array", Name: "子条件", Rank: 2, + Desc: "子条件节点,递归结构", + }, + {ID: 12, PID: 11, Code: "items", DataType: "object", RefID: 1, Rank: 0}, + // fields 用 oneOf 子行表示 + { + ID: 15, PID: 0, Code: "fields", DataType: "array", Name: "查询字段", Rank: 2, + Desc: "查询字段,不传则返回所有字段", + }, + { + ID: 55, PID: 15, Code: "oneOf", DataType: "string", Name: "字段", Rank: 0, + Desc: "直接填字段编码,默认归属于顶层 resource。等价于 {column: \"field\", columnResource: resource}", + }, + {ID: 16, PID: 15, Code: "oneOf", DataType: "object", Name: "字段引用", Rank: 1}, + {ID: 44, PID: 16, Code: "column", DataType: "string", Name: "字段", Rank: 0, IsRequired: true}, + { + ID: 46, PID: 16, Code: "columnResource", DataType: "string", Name: "字段资源", Rank: 1, + Desc: "字段所属资源标识,取值为顶层 resource 或 relations[].code", + }, + {ID: 45, PID: 16, Code: "alias", DataType: "string", Name: "结果别名", Rank: 2}, + {ID: 17, PID: 15, Code: "oneOf", DataType: "object", Name: "表达式字段", Rank: 2}, + {ID: 18, PID: 17, Code: "expr", DataType: "string", Name: "SQL 表达式", Rank: 0, IsRequired: true, + Desc: "{column} / {columnResource}.{column} / SQL 表达式"}, + {ID: 19, PID: 17, Code: "alias", DataType: "string", Name: "结果别名", Rank: 1}, + + // orderBy:items 用 oneOf 表示三种形式,每个变体含 direction + { + ID: 20, PID: 0, Code: "orderBy", DataType: "array", Name: "排序规则", Rank: 3, + Desc: "排序规则列表,多项按顺序依次应用", + }, + + {ID: 48, PID: 20, Code: "oneOf", DataType: "object", Name: "资源字段排序", Rank: 1}, + {ID: 49, PID: 48, Code: "column", DataType: "string", Name: "字段", Rank: 0, IsRequired: true}, + { + ID: 50, PID: 48, Code: "columnResource", DataType: "string", Name: "字段资源", Rank: 1, + Desc: "字段所属资源标识,取值为顶层 resource 或 relations[].code", + }, + { + ID: 53, PID: 48, Code: "direction", RefID: 56, + }, + + {ID: 51, PID: 20, Code: "oneOf", DataType: "object", Name: "SQL 表达式排序", Rank: 2}, + {ID: 52, PID: 51, Code: "expr", DataType: "string", Name: "字段表达式", Rank: 0, IsRequired: true}, + { + ID: 54, PID: 51, Code: "direction", RefID: 56, + }, + + { + ID: 57, PID: 20, Code: "oneOf", DataType: "string", Name: "SQL 表达式 (ASC)", Rank: 0, + Desc: "直接填字段编码,默认升序(asc)", + }, + // groupBy:分组字段 + { + ID: 24, PID: 0, Code: "groupBy", DataType: "array", Name: "分组规则", Rank: 4, + Desc: "GROUP BY 字段列表,配合 fields 中的聚合字段使用", + }, + { + ID: 58, PID: 24, Code: "oneOf", DataType: "string", Name: "分组表达式", Desc: "{column} / {columnResource}.{column} / SQL 表达式", Rank: 0, + }, + { + ID: 59, PID: 24, Code: "oneOf", DataType: "object", Name: "资源字段分组", Rank: 0, + }, + {ID: 60, PID: 59, Code: "column", DataType: "string", Name: "字段编码", Rank: 0, IsRequired: true}, + { + ID: 61, PID: 59, Code: "columnResource", DataType: "string", Name: "所属资源", Rank: 1, + Desc: "字段所属资源标识,取值为顶层 resource 或 relations[].code", + }, + // limit / offset:分页 + { + ID: 26, PID: 0, Code: "limit", DataType: "integer", Name: "每页条数", Rank: 5, + Desc: "返回的最大记录数,默认 30", DefaultVal: "30", Constraints: map[string]any{"maximum": 50}, + }, + { + ID: 27, PID: 0, Code: "offset", DataType: "integer", Name: "偏移量", Rank: 6, + Desc: "跳过的记录数,默认 0", + }, + // relations:资源关联 + { + ID: 28, PID: 0, Code: "relations", DataType: "array", Name: "资源关联", Rank: 7, + Desc: "关联配置列表。\ninner / left / right:SQL JOIN,要求关联资源与主资源在同一数据库连接,将关联字段合并到主查询行。\nhasOne / hasMany:独立子查询,支持跨数据库连接,结果聚合为 JSON 字段(hasOne 为单对象,hasMany 为数组)。\n跨库关联必须使用 hasOne 或 hasMany,不可使用 JOIN 类型。", + }, + {ID: 29, PID: 28, Code: "items", DataType: "object", Rank: 0}, + { + ID: 30, PID: 29, Code: "code", DataType: "string", Name: "关联标识", Rank: 0, + Desc: "关联唯一标识,同时作为别名:JOIN 时为表别名(AS code),子查询时为结果 JSON 字段的键名", + IsRequired: true, + }, + { + ID: 38, PID: 29, Code: "name", DataType: "string", Name: "关联名称", Rank: 1, + Desc: "关联的显示名称", + }, + { + ID: 39, PID: 29, Code: "type", DataType: "string", Name: "关联类型", Rank: 2, + Desc: `关联模式: +inner / left / right:SQL JOIN,要求与主资源同一数据库连接,将关联字段合并到主查询行 +hasOne:独立子查询,支持跨库,结果聚合为单个对象(result[code] = {}) +hasMany:独立子查询,支持跨库,结果聚合为对象数组(result[code] = [...]) +跨数据库连接的关联必须使用 hasOne 或 hasMany`, + Constraints: map[string]any{"enum": []any{"inner", "left", "right", "hasOne", "hasMany"}}, + }, + {ID: 40, PID: 29, Code: "actuallyResource", DataType: "string", Name: "实际资源", Rank: 3, + Desc: "被关联的目标资源编码(相当于 SQL 表名)。JOIN 拼接为:{type} JOIN {actuallyResource} AS {code} ON {code}.{actuallyField} = {relationResource}.{relationField}"}, + {ID: 41, PID: 29, Code: "actuallyField", DataType: "string", Name: "实际资源字段", Rank: 4, + Desc: "目标资源(code)参与 ON 条件的字段"}, + {ID: 42, PID: 29, Code: "relationResource", DataType: "string", Name: "被关联资源", Rank: 5, + Desc: "ON 条件对端的资源编码(通常为主资源或父关联资源)"}, + {ID: 43, PID: 29, Code: "relationField", DataType: "string", Name: "被关联字段", Rank: 6, + Desc: "ON 条件对端资源的字段"}, + {ID: 31, PID: 29, Code: "conditions", RefID: 1, Rank: 7, Desc: "关联的过滤条件"}, + {ID: 33, PID: 29, Code: "orderBy", RefID: 20, Rank: 8, Desc: "关联排序,仅 hasOne/hasMany 有效"}, + {ID: 34, PID: 29, Code: "groupBy", RefID: 24, Rank: 9, Desc: "关联分组,仅 hasOne/hasMany 有效"}, + {ID: 35, PID: 29, Code: "limit", RefID: 26, Rank: 10, Desc: "关联条数限制,仅 hasOne/hasMany 有效"}, + {ID: 36, PID: 29, Code: "offset", RefID: 27, Rank: 11, Desc: "关联偏移量,仅 hasOne/hasMany 有效"}, +} + +func TestResApiParams_PrintSchema(t *testing.T) { + b, _ := json.MarshalIndent(queryParams.ToJSONSchema(), "", " ") + t.Log("\n" + string(b)) +} + +func TestResApiParams_ToJSONSchema(t *testing.T) { + schema := queryParams.ToJSONSchema() + + if schema.Type != "object" { + t.Fatalf("expected type=object, got %s", schema.Type) + } + + cond, ok := schema.Properties["conditions"] + if !ok { + t.Fatal("missing property: conditions") + } + if cond.Type != "object" { + t.Errorf("conditions: expected type=object, got %s", cond.Type) + } + if cond.Description != "WHERE 条件树。\nexprs 每项生成一段比较:[{columnSqlFunc}(]{columnResource}.{column}[)] {operator} {value},同层多项以 type(and/or) 连接;\nchildren 为同结构子节点,递归嵌套实现复杂过滤。" { + t.Errorf("conditions: unexpected description: %s", cond.Description) + } + + // conditions.type 枚举和默认值 + typeProp, ok := cond.Properties["type"] + if !ok { + t.Fatal("missing property: conditions.type") + } + if len(typeProp.Enum) != 2 { + t.Errorf("conditions.type: expected 2 enum values, got %d", len(typeProp.Enum)) + } + var defaultVal string + if err := json.Unmarshal(typeProp.Default, &defaultVal); err != nil || defaultVal != "and" { + t.Errorf("conditions.type: expected default=and, got %s", typeProp.Default) + } + + // conditions.exprs 是 array,items 是 object + exprsProp, ok := cond.Properties["exprs"] + if !ok { + t.Fatal("missing property: conditions.exprs") + } + if exprsProp.Type != "array" { + t.Errorf("conditions.exprs: expected type=array, got %s", exprsProp.Type) + } + if exprsProp.Items == nil { + t.Fatal("conditions.exprs: missing items") + } + if exprsProp.Items.Type != "object" { + t.Errorf("conditions.exprs.items: expected type=object, got %s", exprsProp.Items.Type) + } + + // exprs.items required: column, operator + requiredSet := map[string]bool{} + for _, r := range exprsProp.Items.Required { + requiredSet[r] = true + } + if !requiredSet["column"] || !requiredSet["operator"] { + t.Errorf("conditions.exprs.items: expected required=[column, operator], got %v", exprsProp.Items.Required) + } + + // children.items $ref + childrenProp, ok := cond.Properties["children"] + if !ok { + t.Fatal("missing property: conditions.children") + } + if childrenProp.Items == nil { + t.Fatal("conditions.children: missing items") + } + if childrenProp.Items.Ref != "#/properties/conditions" { + t.Errorf("conditions.children.items: expected $ref=#/properties/conditions, got %s", childrenProp.Items.Ref) + } +} + +func TestResApiParamsFromJSONSchema(t *testing.T) { + original := queryParams.ToJSONSchema() + params := base.ResApiParamsFromJSONSchema(original) + + if len(params) == 0 { + t.Fatal("ResApiParamsFromJSONSchema returned empty params") + } + + // 转回 schema 后结构应一致 + rebuilt := params.ToJSONSchema() + + // 将 original 和 rebuilt 都序列化为 map[string]any 后 DeepEqual 比较 + toMap := func(s *jschema.Schema) map[string]any { + b, _ := json.Marshal(s) + var m map[string]any + json.Unmarshal(b, &m) + return m + } + originalMap := toMap(original) + rebuiltMap := toMap(rebuilt) + + if !reflect.DeepEqual(originalMap, rebuiltMap) { + ob, _ := json.MarshalIndent(originalMap, "", " ") + rb, _ := json.MarshalIndent(rebuiltMap, "", " ") + t.Errorf("schema mismatch:\noriginal: %s\nrebuilt: %s", ob, rb) + } +} + +// createParams res-create 接口参数,data 字段为 any 类型(oneOf object | array) +var createParams = base.ResApiParams{ + {ID: 315, PID: 0, Code: "resource", DataType: "string", Name: "主资源", Rank: 1, + Desc: "目标资源编码,数据将写入该资源对应的表"}, + {ID: 316, PID: 0, Code: "data", DataType: "any", Name: "创建数据", Rank: 2, + Desc: "单条记录传对象,批量创建传对象数组"}, + // oneOf 分支1:单条记录(object + additionalProperties: true) + {ID: 317, PID: 316, Code: "oneOf", DataType: "json", Name: "单条记录", Rank: 1, + Desc: "创建单条记录,key 为字段编码,value 为字段值", + Constraints: map[string]any{"additionalProperties": true}}, + // oneOf 分支2:批量记录(array,items 为 object + additionalProperties: true) + {ID: 318, PID: 316, Code: "oneOf", DataType: "array", Name: "批量记录", Rank: 2, + Desc: "批量创建,每个元素为一条记录对象"}, + {ID: 319, PID: 318, Code: "items", DataType: "json", Name: "记录对象", Rank: 1, + Desc: "批量创建,每个元素为一条记录对象", + Constraints: map[string]any{"additionalProperties": true}}, +} + +func TestResApiParams_AnyType(t *testing.T) { + schema := createParams.ToJSONSchema() + + // data 字段存在 + data, ok := schema.Properties["data"] + if !ok { + t.Fatal("missing property: data") + } + + // any 类型不应有 type + if data.Type != "" { + t.Errorf("data: expected empty type for any, got %q", data.Type) + } + + // oneOf 应有 2 个分支 + if len(data.OneOf) != 2 { + t.Fatalf("data.oneOf: expected 2 branches, got %d", len(data.OneOf)) + } + + // 分支1:object + branch1 := data.OneOf[0] + if branch1.Type != "object" { + t.Errorf("data.oneOf[0]: expected type=object, got %q", branch1.Type) + } + if branch1.AdditionalProperties == nil { + t.Error("data.oneOf[0]: expected additionalProperties to be set") + } + + // 分支2:array,items 为 object + branch2 := data.OneOf[1] + if branch2.Type != "array" { + t.Errorf("data.oneOf[1]: expected type=array, got %q", branch2.Type) + } + if branch2.Items == nil { + t.Fatal("data.oneOf[1]: missing items") + } + if branch2.Items.Type != "object" { + t.Errorf("data.oneOf[1].items: expected type=object, got %q", branch2.Items.Type) + } + if branch2.Items.AdditionalProperties == nil { + t.Error("data.oneOf[1].items: expected additionalProperties to be set") + } +} + +func TestResApiParams_AdditionalProperties(t *testing.T) { + params := base.ResApiParams{ + {ID: 1, PID: 0, Code: "open", DataType: "json", Name: "开放对象", + Constraints: map[string]any{"additionalProperties": true}}, + {ID: 2, PID: 0, Code: "strict", DataType: "json", Name: "严格对象", + Constraints: map[string]any{"additionalProperties": false}}, + } + + schema := params.ToJSONSchema() + + open := schema.Properties["open"] + if open.AdditionalProperties == nil { + t.Fatal("open: expected additionalProperties to be set") + } + // true → Not == nil + if open.AdditionalProperties.Not != nil { + t.Error("open: additionalProperties should be true (Not == nil)") + } + + strict := schema.Properties["strict"] + if strict.AdditionalProperties == nil { + t.Fatal("strict: expected additionalProperties to be set") + } + // false → Not != nil + if strict.AdditionalProperties.Not == nil { + t.Error("strict: additionalProperties should be false (Not != nil)") + } +} + +func TestResApiParams_AnyType_Print(t *testing.T) { + b, _ := json.MarshalIndent(createParams.ToJSONSchema(), "", " ") + t.Log("\n" + string(b)) +} + +func TestResApiParams_RoundTrip(t *testing.T) { + input := &jschema.Schema{ + Type: "object", + Properties: map[string]*jschema.Schema{ + "name": { + Type: "string", + Description: "姓名", + MinLength: jschema.Ptr(1), + MaxLength: jschema.Ptr(100), + }, + "age": { + Type: "integer", + Minimum: jschema.Ptr(float64(0)), + Maximum: jschema.Ptr(float64(150)), + }, + "tags": { + Type: "array", + Items: &jschema.Schema{Type: "string"}, + }, + }, + Required: []string{"name"}, + } + + params := base.ResApiParamsFromJSONSchema(input) + output := params.ToJSONSchema() + + if _, ok := output.Properties["name"]; !ok { + t.Error("round-trip: missing property name") + } + if _, ok := output.Properties["age"]; !ok { + t.Error("round-trip: missing property age") + } + tags, ok := output.Properties["tags"] + if !ok { + t.Error("round-trip: missing property tags") + } else if tags.Items == nil || tags.Items.Type != "string" { + t.Errorf("round-trip: tags.items expected type=string, got %+v", tags.Items) + } +} diff --git a/base/res_api_param_test1.json b/base/res_api_param_test1.json new file mode 100644 index 0000000..910691e --- /dev/null +++ b/base/res_api_param_test1.json @@ -0,0 +1,344 @@ +{ + "tools": [ + { + "name": "res-query", + "description": "Query records with filters, joins, aggregates, ordering, and pagination.\rThe main resource and each hasOne/hasMany relation are independent query contexts; inner/left/right are JOINs within their parent context.\rTop-level conditions / groupBy / orderBy / limit / offset apply to the main resource SQL. Each relation has its own conditions / groupBy / orderBy / limit that apply to that relation's SQL.\rIMPORTANT:\r(1) All field codes MUST come from res-schema results already in this conversation — never guess or infer field names.\r(2) Fields belonging to a hasOne/hasMany context must use object form with codeResource set to that relation's code.\r(3) Call res-schema first if the resource schema has not been fetched yet.", + "inputSchema": { + "type": "object", + "properties": { + "conditions": { + "type": "object", + "properties": { + "children": { + "type": "array", + "items": { + "$ref": "#/properties/conditions" + }, + "title": "子条件", + "description": "子条件节点,递归结构" + }, + "exprs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "field": { + "type": "string", + "title": "字段编码", + "description": "必须来自 res-schema 返回的字段列表,禁止猜测字段名" + }, + "fieldResource": { + "type": "string", + "title": "所属资源", + "description": "字段所属资源标识,取值为主资源编码或当前查询上下文(conditions 所在层级)对应的 relations[].code" + }, + "fieldSqlFunc": { + "type": "string", + "title": "列SQL函数", + "description": "对列应用 SQL 函数后再参与比较,拼接为 {func}({fieldResource}.{field}) {operator} {token}。\r\n支持 SQL-92 / SQL:1999 / SQL:2003 标准函数及数据库方言函数,如 DATE、UPPER、ROUND。\r\n需要第二参数的函数(如 ROUND、CAST)配合 fieldSqlFuncParam 使用。" + }, + "fieldSqlFuncParam": { + "type": "string", + "title": "列SQL函数参数", + "description": "fieldSqlFunc 的附加参数,按函数类型用途不同:\r\r通用函数(有 param 时):{func}({fieldResource}.{field}, {fieldSqlFuncParam}) {operator} {token}\r 示例:ROUND(col, 2) → fieldSqlFuncParam=\\\"2\\\"\r SUBSTRING(col, 1, 10) → fieldSqlFuncParam=\\\"1, 10\\\"" + }, + "ignoreEmptyParam": { + "type": "boolean", + "title": "忽略空参数", + "description": "当 tokenType=param 且请求参数值为空(空字符串或 null)时,跳过该条件不生成 SQL。用于实现可选过滤项", + "default": false + }, + "operator": { + "type": "string", + "title": "运算符", + "description": "标准 SQL 比较运算符:\r\n= / != / > / >= / < / <=\r\nLIKE:模糊匹配(%keyword%)\r\nIN:包含,value 逗号分隔多值\r\nIS NULL / IS NOT NULL:空值判断,无需 value\r\nREGEXP:正则匹配", + "default": "=" + }, + "token": { + "type": "string", + "title": "比较值", + "description": "比较目标,内容由 tokenType 决定。operator 为 IS NULL/IS NOT NULL 时可省略" + }, + "tokenType": { + "type": "string", + "title": "比较值类型", + "description": "先选类型再填 token:\r\nstring:token 填字面量如 '2024-01-01'\r\nfunc:token 填内置函数名 UserID|UserUuid|UserRolesUuid|UserPlatform|UserSaaS\r\nparam:token 填请求参数路径如 body.status\r\nsql:token 填原始SQL如 CURRENT_DATE", + "default": "string", + "enum": [ + "string", + "func" + ] + } + }, + "title": "条件项", + "description": "单条比较表达式,生成 [{fieldSqlFunc}(]{fieldResource}.{field}[)] {operator} {token} 片段", + "required": [ + "field", + "operator" + ] + }, + "title": "条件表达式列表", + "description": "当前层的叶子比较列表,每项生成一个 {fieldResource}.{field} {operator} {token} 片段,同层多项以 type 连接" + }, + "type": { + "type": "string", + "title": "逻辑连接符", + "description": "当前节点的逻辑运算符,将 exprs 各项与 children 各组以 AND 或 OR 连接", + "default": "and", + "enum": [ + "and", + "or" + ] + } + }, + "title": "树形查询条件", + "description": "WHERE 条件树。\rexprs 每项生成一段比较:[{fieldSqlFunc}(]{fieldResource}.{field}[)] {operator} {token},同层多项以 type(and/or) 连接;\rchildren 为同结构子节点,递归嵌套实现复杂过滤。", + "required": [ + "exprs", + "type" + ] + }, + "fields": { + "type": "array", + "items": { + "type": "object", + "properties": { + "alias": { + "type": "string", + "title": "别名", + "description": "输出字段别名,对应 SQL AS。isExpr=true 时必填,否则结果列无法被引用;普通字段不填则默认使用 code 值" + }, + "code": { + "type": "string", + "title": "字段编码", + "description": "两种互斥用法,每个对象只能选其一:\r1. 字段编码,格式为 {Resource}.{field},Resource 取值为主资源编码或 relations[].code,如 User.amount / Order.price,isExpr 保持默认 false\r2. 原始 SQL 表达式(如 COUNT(*) / SUM(amount)),必须同时设置 isExpr = true,且 alias 必填\r注意:SELECT 中混入聚合表达式与非聚合字段时,需配合 groupBy 使用,否则结果不符合预期\r重要:字段编码必须来自 res-schema 返回的字段列表,禁止猜测或推断字段名" + }, + "codeResource": { + "type": "string", + "title": "所属资源", + "description": "字段所属的表标识,取值为主资源编码或 relations[].code" + }, + "dataType": { + "type": "string", + "title": "数据类型", + "description": "指定字段的返回数据类型,影响序列化方式;不填则由资源字段定义决定", + "default": "string", + "enum": [ + "string", + "bool", + "number", + "array", + "json", + "integer", + "float" + ] + }, + "isExpr": { + "type": "boolean", + "title": "是否表达式", + "description": "设为 true 时,code 内容作为原始 SQL 表达式拼入 SELECT(如聚合函数 COUNT(*) / SUM(amount)),而非普通字段编码。isExpr=true 时 alias 为必填项", + "default": false + } + }, + "title": "资源字段", + "description": "结构化字段,支持指定所属资源、输出别名、聚合表达式及数据类型", + "required": [ + "code", + "codeResource" + ] + }, + "title": "查询字段", + "description": "SELECT 字段列表,涵盖所有查询上下文(主资源 + 所有 hasOne/hasMany),通过 codeResource 路由到对应的独立 SQL。不传则返回所有字段。\r主资源和每个 hasOne/hasMany 各是一个独立查询上下文,inner/left/right 只是其所在上下文内的 JOIN。\r仅查询聚合结果(如 COUNT(*) / SUM(amount))时,fields 只传聚合字段,不要混入普通字段,除非同时提供 groupBy。\r重要:所有字段编码必须先通过 res-schema 确认,禁止凭名称语义猜测" + }, + "groupBy": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string", + "title": "字段编码", + "description": "格式必须为 {Resource}.{field},如 User.status。\r{Resource} 取值为主资源编码或 relations[].code" + }, + { + "type": "object", + "properties": { + "expr": { + "type": "string", + "title": "表达式", + "description": "type=field 时填 {Resource}.{field}(如 User.status)\rtype=sql 时填原始 SQL 表达式(如 DATE(created_at))" + }, + "type": { + "type": "string", + "title": "表达式类型", + "description": "field:按资源字段分组,expr 填 {Resource}.{field},如 User.status\rsql:按原始 SQL 表达式分组,expr 填任意合法 SQL,如 DATE(created_at)", + "default": "sql", + "enum": [ + "field", + "sql" + ] + } + }, + "title": "分组表达式", + "description": "结构化分组项,type=field 用于资源字段,type=sql 用于原始 SQL 表达式", + "required": [ + "expr", + "type" + ] + } + ] + }, + "title": "分组规则", + "description": "GROUP BY 列表,配合 fields 中的聚合表达式使用。\r字段必须带资源前缀({Resource}.{field});聚合函数(COUNT / SUM 等)放在 fields 中,不属于此处" + }, + "limit": { + "type": "integer", + "title": "每页条数", + "description": "当前查询上下文返回的最大记录数(顶层时限制主资源结果,relations 内时限制该关联结果)", + "default": 30 + }, + "offset": { + "type": "integer", + "title": "偏移量", + "description": "主资源查询跳过的记录数,仅顶层有效,relations 内不支持", + "default": 0 + }, + "orderBy": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string", + "title": "字段编码", + "description": "格式必须为 {Resource}.{field},如 User.created_at,默认升序(ASC)。\r需要降序或使用 SQL 表达式时使用对象形式" + }, + { + "type": "object", + "properties": { + "direction": { + "type": "string", + "title": "排序方向", + "default": "asc", + "enum": [ + "asc", + "desc" + ] + }, + "expr": { + "type": "string", + "title": "表达式", + "description": "type=field 时填 {Resource}.{field}(如 User.created_at);type=sql 时填原始 SQL 表达式(如 COUNT(*) / DATE(created_at))" + }, + "type": { + "type": "string", + "title": "表达式类型", + "description": "field:按资源字段排序,expr 填 {Resource}.{field},如 User.created_at\rsql:按原始 SQL 表达式排序,expr 填任意合法 SQL,如 COUNT(*) / DATE(created_at)", + "default": "field", + "enum": [ + "field", + "sql" + ] + } + }, + "title": "排序表达式", + "description": "结构化排序项,type=field 用于资源字段,type=sql 用于原始 SQL 表达式", + "required": [ + "type", + "expr" + ] + } + ] + }, + "title": "排序规则", + "description": "ORDER BY 列表,多项按顺序依次应用。\r字段必须带资源前缀({Resource}.{field});需要降序或 SQL 表达式排序时使用对象形式" + }, + "relations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "actuallyField": { + "type": "string", + "title": "实际资源字段", + "description": "目标资源(code)参与 ON 条件的字段" + }, + "actuallyResource": { + "type": "string", + "title": "实际资源", + "description": "被关联的目标资源编码(相当于 SQL 表名)。JOIN 拼接为:{type} JOIN {actuallyResource} AS {code} ON {code}.{actuallyField} = {relationResource}.{relationField}" + }, + "code": { + "type": "string", + "title": "关联标识", + "description": "关联唯一标识,同时作为别名:JOIN 时为表别名(AS code),子查询时为结果 JSON 字段的键名。\r通常与 actuallyResource 保持一致;仅当多个关联引用同一资源导致冲突时,才需要重命名加以区分" + }, + "conditions": { + "$ref": "#/properties/conditions", + "description": "该关联的过滤条件,结构与顶层 conditions 相同。hasOne/hasMany 作用于子查询 WHERE;inner/left/right 作用于 JOIN ON 或主查询 WHERE" + }, + "groupBy": { + "$ref": "#/properties/groupBy", + "description": "该关联的 GROUP BY,仅 hasOne/hasMany 子查询生效,结构与顶层 groupBy 相同" + }, + "limit": { + "$ref": "#/properties/limit", + "description": "该关联的最大返回条数,仅 hasOne/hasMany 子查询生效,结构与顶层 limit 相同" + }, + "name": { + "type": "string", + "title": "关联名称", + "description": "关联的显示名称" + }, + "orderBy": { + "$ref": "#/properties/orderBy", + "description": "该关联的 ORDER BY,仅 hasOne/hasMany 子查询生效,结构与顶层 orderBy 相同" + }, + "relationField": { + "type": "string", + "title": "被关联资源字段", + "description": "ON 条件对端资源的字段" + }, + "relationResource": { + "type": "string", + "title": "被关联资源", + "description": "ON 条件对端的资源编码(通常为主资源或父关联资源)" + }, + "type": { + "type": "string", + "title": "关联类型", + "description": "关联模式:\r\ninner / left / right:SQL JOIN,要求与主资源同一数据库连接,将关联字段合并到主查询行\r\nhasOne:独立子查询,支持跨库,结果聚合为单个对象(result[code] = {})\r\nhasMany:独立子查询,支持跨库,结果聚合为对象数组(result[code] = [...])\r\n跨数据库连接的关联必须使用 hasOne 或 hasMany", + "enum": [ + "inner", + "left", + "right", + "hasOne", + "hasMany" + ] + } + }, + "required": [ + "actuallyField", + "actuallyResource", + "code", + "relationField", + "relationResource", + "type" + ] + }, + "title": "资源关联", + "description": "关联配置列表。\rinner / left / right:SQL JOIN,要求关联资源与主资源在同一数据库连接,将关联字段合并到主查询行。\rhasOne / hasMany:独立子查询,支持跨数据库连接,结果聚合为 JSON 字段(hasOne 为单对象,hasMany 为数组)。\r跨库关联必须使用 hasOne 或 hasMany,不可使用 JOIN 类型。" + }, + "resource": { + "type": "string", + "title": "主资源", + "description": "主资源编码(resource_code),作为本次查询的根上下文,relations 中的关联资源均以此为基础展开" + } + }, + "required": [ + "fields", + "resource" + ] + }, + "annotations": {} + } + ] +} \ No newline at end of file diff --git a/base/res_api_param_test2.json b/base/res_api_param_test2.json new file mode 100644 index 0000000..c2f070b --- /dev/null +++ b/base/res_api_param_test2.json @@ -0,0 +1,475 @@ +{ + "tools": [ + { + "name": "res-query", + "description": "Query records with filters, joins, aggregates, ordering, pagination, and recursive tree traversal.\rAll relations are SQL JOINs within a single query; the main resource is the primary table.\rPrefer JOIN over multiple queries: when the needed data spans several resources, combine same-connection resources into one query using relations, then make separate queries only for resources on different connections.\rWRONG: 3 queries — User → UserRole → Role, even though User and UserRole share the same connection.\rRIGHT: 1 query joining User + UserRole (same connection), then 1 separate query for Role (different connection).\rIMPORTANT:\r(1) Never guess field names — only use codes from res-schema results in this conversation.\r(2) Call res-schema first if the resource schema has not been fetched yet.\r(3) Use recursive to traverse all descendants of hierarchical data (e.g. categories, menus, org charts); returns a flat list, not a nested tree.", + "inputSchema": { + "type": "object", + "properties": { + "conditions": { + "type": "object", + "properties": { + "children": { + "type": "array", + "items": { + "$ref": "#/properties/conditions" + }, + "title": "子条件", + "description": "子条件节点,递归结构" + }, + "exprs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "field": { + "type": "string", + "title": "字段编码", + "description": "必须来自 res-schema 返回的字段列表,禁止猜测字段名" + }, + "fieldResource": { + "type": "string", + "title": "所属资源", + "description": "字段所属的表标识,取值为主资源编码或 relations[].code" + }, + "fieldSqlFunc": { + "type": "string", + "title": "列SQL函数", + "description": "对列应用 SQL 函数后再参与比较,拼接为 {func}({fieldResource}.{field}) {operator} {token}。\r\n支持 SQL-92 / SQL:1999 / SQL:2003 标准函数及数据库方言函数,如 DATE、UPPER、ROUND。\r\n需要第二参数的函数(如 ROUND、CAST)配合 fieldSqlFuncParam 使用。" + }, + "fieldSqlFuncParam": { + "type": "string", + "title": "列SQL函数参数", + "description": "fieldSqlFunc 的附加参数,按函数类型用途不同:\r\r通用函数(有 param 时):{func}({fieldResource}.{field}, {fieldSqlFuncParam}) {operator} {token}\r 示例:ROUND(col, 2) → fieldSqlFuncParam=\"2\"\r SUBSTRING(col, 1, 10) → fieldSqlFuncParam=\"1, 10\"\r\rJSON 函数(param 为 JSON 路径表达式):\r json_member_of(token 是否是列 JSON 数组的成员):\r 无 param:JSON_CONTAINS({token}, JSON_ARRAY({field}))\r 有 param:JSON_CONTAINS({fieldSqlFuncParam}, JSON_ARRAY({field}))\r json_contains(列 JSON 是否包含 token):\r 无 param:JSON_CONTAINS({field}, JSON_ARRAY({token}))\r 有 param:JSON_CONTAINS({fieldSqlFuncParam}, JSON_ARRAY({token}))" + }, + "ignoreEmptyParam": { + "type": "boolean", + "title": "忽略空参数", + "description": "当 tokenType=param 且请求参数值为空(空字符串或 null)时,跳过该条件不生成 SQL。用于实现可选过滤项", + "default": false + }, + "operator": { + "type": "string", + "title": "运算符", + "description": "标准 SQL 比较运算符:\r\n= / != / > / >= / < / <=\r\nLIKE:模糊匹配(%keyword%)\r\nIN:包含,token 逗号分隔多值\r\nIS NULL / IS NOT NULL:空值判断,无需 token\r\nREGEXP:正则匹配", + "default": "=" + }, + "token": { + "type": "string", + "title": "比较值", + "description": "比较目标,内容由 tokenType 决定。operator 为 IS NULL/IS NOT NULL 时可省略" + }, + "tokenType": { + "type": "string", + "title": "比较值类型", + "description": "先选类型再填 token:\r\nstring:token 填字面量如 '2024-01-01'\r\nfunc:token 填内置函数名 UserID|UserUuid|UserRolesUuid|UserPlatform|UserSaaS\r\nparam:token 填请求参数路径如 body.status\r\nsql:token 填原始SQL如 CURRENT_DATE", + "default": "string", + "enum": [ + "string", + "func" + ] + } + }, + "title": "条件项", + "description": "单条比较表达式,生成 [{fieldSqlFunc}(]{fieldResource}.{field}[)] {operator} {token} 片段", + "required": [ + "field", + "operator" + ] + }, + "title": "条件表达式列表", + "description": "当前层的叶子比较列表,每项生成一个 {fieldResource}.{field} {operator} {token} 片段,同层多项以 type 连接" + }, + "type": { + "type": "string", + "title": "逻辑连接符", + "description": "当前节点的逻辑运算符,将 exprs 各项与 children 各组以 AND 或 OR 连接", + "default": "and", + "enum": [ + "and", + "or" + ] + } + }, + "title": "树形查询条件", + "description": "WHERE 条件树。\rexprs 每项生成一段比较:[{fieldSqlFunc}(]{fieldResource}.{field}[)] {operator} {token},同层多项以 type(and/or) 连接;\rchildren 为同结构子节点,递归嵌套实现复杂过滤。", + "required": [ + "exprs", + "type" + ] + }, + "fields": { + "type": "array", + "items": { + "type": "object", + "properties": { + "alias": { + "type": "string", + "title": "别名", + "description": "输出字段别名,对应 SQL AS。isExpr=true 时必填;普通字段在多表 JOIN 存在同名冲突时填写,其余情况默认使用 code 值" + }, + "code": { + "type": "string", + "title": "字段编码", + "description": "两种互斥用法,每个对象只能选其一:\r1. 普通字段编码,只填字段名本身如 amount,不含资源前缀,所属表由 codeResource 单独指定,isExpr 保持默认 false\r2. 原始 SQL 表达式(如 COUNT(*) / SUM(User.amount)),可含 {Resource}.{field} 引用,必须同时设置 isExpr = true 且 alias 必填\r注意:SELECT 中混入聚合表达式与非聚合字段时,需配合 groupBy 使用,否则结果不符合预期\r重要:字段编码必须来自 res-schema 返回的字段列表,禁止猜测或推断字段名" + }, + "codeResource": { + "type": "string", + "title": "所属资源", + "description": "字段所属的表标识,取值为主资源编码或 relations[].code" + }, + "dataType": { + "type": "string", + "title": "数据类型", + "description": "指定字段的返回数据类型,影响序列化方式;不填则由资源字段定义决定", + "default": "string", + "enum": [ + "string", + "bool", + "number", + "array", + "json", + "integer", + "float" + ] + }, + "isExpr": { + "type": "boolean", + "title": "是否表达式", + "description": "设为 true 时,code 内容作为原始 SQL 表达式拼入 SELECT(如聚合函数 COUNT(*) / SUM(amount)),而非普通字段编码。isExpr=true 时 alias 为必填项", + "default": false + } + }, + "title": "资源字段", + "description": "结构化字段,支持指定所属表、输出别名、聚合表达式及数据类型", + "required": [ + "code" + ] + }, + "title": "查询字段", + "description": "SELECT 字段列表,不传则返回所有字段(仅主资源字段,不含 JOIN 表)。\r使用 relations 时,JOIN 表的字段不会自动出现在结果中,必须在 fields 里显式列出需要的 JOIN 表字段,否则 JOIN 无意义。\r普通字段:code 填字段名,codeResource 指定所属表;聚合/表达式:isExpr=true,code 填 SQL 表达式,alias 必填。\r仅查询聚合结果时,fields 只传聚合字段,不要混入普通字段,除非同时提供 groupBy。\r重要:所有字段编码必须先通过 res-schema 确认,禁止凭名称语义猜测" + }, + "groupBy": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string", + "title": "字段编码", + "description": "格式必须为 {Resource}.{field},如 User.status。\r{Resource} 取值为主资源编码或 relations[].code" + }, + { + "type": "object", + "properties": { + "expr": { + "type": "string", + "title": "表达式", + "description": "type=field 时填 {Resource}.{field}(如 User.status)\rtype=sql 时填原始 SQL 表达式(如 DATE(created_at))" + }, + "type": { + "type": "string", + "title": "表达式类型", + "description": "field:按资源字段分组,expr 填 {Resource}.{field},如 User.status\rsql:按原始 SQL 表达式分组,expr 填任意合法 SQL,如 DATE(created_at)", + "default": "field", + "enum": [ + "field", + "sql" + ] + } + }, + "title": "分组表达式", + "description": "结构化分组项,type=field 用于资源字段,type=sql 用于原始 SQL 表达式", + "required": [ + "type", + "expr" + ] + } + ] + }, + "title": "分组规则", + "description": "GROUP BY 列表,配合 fields 中的聚合表达式使用。\r字段必须带资源前缀({Resource}.{field});聚合函数(COUNT / SUM 等)放在 fields 中,不属于此处" + }, + "limit": { + "type": "integer", + "title": "每页条数", + "description": "返回的最大记录数", + "default": 30 + }, + "offset": { + "type": "integer", + "title": "偏移量", + "description": "跳过的记录数", + "default": 0 + }, + "orderBy": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string", + "title": "字段编码", + "description": "格式必须为 {Resource}.{field},如 User.created_at,默认升序(ASC)。\r需要降序或使用 SQL 表达式时使用对象形式" + }, + { + "type": "object", + "properties": { + "direction": { + "type": "string", + "title": "排序方向", + "default": "asc", + "enum": [ + "asc", + "desc" + ] + }, + "expr": { + "type": "string", + "title": "表达式", + "description": "type=field 时填 {Resource}.{field}(如 User.created_at);type=sql 时填原始 SQL 表达式(如 COUNT(*) / DATE(created_at))" + }, + "type": { + "type": "string", + "title": "表达式类型", + "description": "field:按资源字段排序,expr 填 {Resource}.{field},如 User.created_at\rsql:按原始 SQL 表达式排序,expr 填任意合法 SQL,如 COUNT(*) / DATE(created_at)", + "default": "field", + "enum": [ + "field", + "sql" + ] + } + }, + "title": "排序表达式", + "description": "结构化排序项,type=field 用于资源字段,type=sql 用于原始 SQL 表达式", + "required": [ + "type", + "expr" + ] + } + ] + }, + "title": "排序规则", + "description": "ORDER BY 列表,多项按顺序依次应用。\r字段必须带资源前缀({Resource}.{field});需要降序或 SQL 表达式排序时使用对象形式" + }, + "relations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "actuallyField": { + "type": "string", + "title": "实际资源字段", + "description": "目标资源(code)参与 ON 条件的字段" + }, + "actuallyResource": { + "type": "string", + "title": "实际资源", + "description": "被关联的目标资源编码(相当于 SQL 表名)。JOIN 拼接为:{type} JOIN {actuallyResource} AS {code} ON {code}.{actuallyField} = {relationResource}.{relationField}" + }, + "code": { + "type": "string", + "title": "关联标识", + "description": "JOIN 表别名(AS code),同时作为 fields/conditions/groupBy/orderBy 中 {Resource} 的取值。\r通常与 actuallyResource 保持一致;仅当多个关联引用同一资源导致冲突时,才需要重命名" + }, + "conditions": { + "$ref": "#/properties/conditions", + "description": "该 JOIN 的附加过滤条件,作用于 JOIN ON 或主查询 WHERE,结构与顶层 conditions 相同" + }, + "name": { + "type": "string", + "title": "关联名称", + "description": "关联的显示名称" + }, + "relationField": { + "type": "string", + "title": "被关联资源字段", + "description": "ON 条件对端资源的字段" + }, + "relationResource": { + "type": "string", + "title": "被关联资源", + "description": "ON 条件对端的资源编码(通常为主资源或父关联资源)" + }, + "type": { + "type": "string", + "title": "关联类型", + "description": "SQL JOIN 类型(仅支持同库连接,不可跨库):\rinner:内连接,只返回两表均匹配的行\rleft:左连接,保留主表所有行\rright:右连接,保留关联表所有行", + "enum": [ + "inner", + "left", + "right" + ] + } + }, + "required": [ + "actuallyField", + "actuallyResource", + "code", + "relationField", + "relationResource", + "type" + ] + }, + "title": "资源关联", + "description": "SQL JOIN 列表,所有关联在同一条 SQL 中执行。\r关联资源的字段通过 code(表别名)在 fields / conditions / groupBy / orderBy 中引用。\r重要:所有关联资源必须与主资源在同一数据库连接。res-schema 返回的资源名称后面标有连接标识(如 · default / · service-support),建立 JOIN 前必须确认主资源与所有关联资源的连接标识完全一致,不一致则不可使用 JOIN,需分次查询后在应用层合并。" + }, + "resource": { + "type": "string", + "title": "主资源", + "description": "主资源编码(resource),作为本次查询的主表,relations 中的关联资源均以此为基础 JOIN" + }, + "recursive": { + "type": "object", + "title": "递归查询", + "description": "树形/层级数据的递归查询配置。配置后会以 pField=root 的记录为根节点,向下递归遍历所有子孙节点,返回平铺的记录列表(非嵌套树)。", + "properties": { + "pField": { + "type": "string", + "title": "父节点字段", + "description": "存储父节点 ID 的字段编码,格式为 {Resource}.{field}。{Resource} 取值为主资源编码或 relations[].code,如 Category.pid" + }, + "cField": { + "type": "string", + "title": "当前节点字段", + "description": "存储自身 ID 的字段编码,格式为 {Resource}.{field}。{Resource} 取值为主资源编码或 relations[].code,如 Category.id" + }, + "root": { + "type": "string", + "title": "根节点值", + "description": "递归起点,pField 等于此值的记录作为根节点,通常为 0 或空字符串" + }, + "depth": { + "type": "integer", + "title": "递归深度", + "description": "最大递归层数,防止循环引用导致无限递归,不传则不限制" + } + }, + "required": ["pField", "cField", "root"] + } + }, + "required": [ + "fields", + "resource" + ] + }, + "annotations": {} + }, + { + "name": "res-create", + "description": "Create one or multiple new records for the specified resource. Returns all fields of the created record(s).\rCall res-schema first to get the field list before building the data object.\rIMPORTANT: Never guess field names — only use codes from res-schema results.", + "inputSchema": { + "type": "object", + "properties": { + "resource": { + "type": "string", + "title": "主资源", + "description": "目标资源编码,数据将写入该资源对应的表" + }, + "data": { + "title": "创建数据", + "description": "单条记录传对象,批量创建传对象数组。\rkey 为字段编码(必须来自 res-schema 返回的字段列表,禁止猜测),value 为写入值。\r有默认值的字段可以不传,系统会自动填充。\r批量创建(array)时,所有记录必须包含完全相同的字段集合,不可部分记录有某字段而其他记录没有。\r系统保留字段禁止传入,包括:id、created_at、updated_at、created_user、owned_user、enabled 及其他由系统自动维护的字段", + "oneOf": [ + { + "type": "object", + "title": "单条记录", + "description": "创建单条记录,key 为字段编码,value 为字段值", + "additionalProperties": true + }, + { + "type": "array", + "title": "批量记录", + "description": "批量创建,每个元素为一条记录对象", + "items": { + "type": "object", + "additionalProperties": true + } + } + ] + } + }, + "required": ["resource", "data"] + }, + "annotations": {} + }, + { + "name": "res-update", + "description": "Update records by their IDs. Returns only the updated fields of the modified records.\rBefore calling this tool, ALWAYS use res-query to retrieve and show the user the records that will be affected. Only call res-update after the user has reviewed and confirmed.\rPass __confirm__: true when the user has approved the update.\rIMPORTANT: Never guess field names — only use codes from res-schema results.", + "inputSchema": { + "type": "object", + "properties": { + "resource": { + "type": "string", + "title": "主资源", + "description": "目标资源编码" + }, + "ids": { + "type": "array", + "title": "记录ID列表", + "description": "必填,指定要更新的记录 ID 列表。必须通过 res-query 预先查询获得,禁止手动构造。", + "items": { + "type": "integer" + } + }, + "data": { + "type": "object", + "title": "更新数据", + "description": "只传需要修改的字段,未传字段保持不变。\rkey 为字段编码(必须来自 res-schema 返回的字段列表,禁止猜测),value 为新值。\r系统保留字段禁止传入,包括:id、created_at、updated_at、created_user、owned_user、enabled 及其他由系统自动维护的字段", + "additionalProperties": true + }, + "__confirm__": { + "type": "boolean", + "title": "确认执行", + "description": "首次调用不传。仅当服务端返回需要确认的提示时,重新调用并传入 true 以执行更新。不可主动传入,否则将跳过安全确认步骤。", + "default": false + } + }, + "required": ["resource", "ids", "data"] + }, + "annotations": {} + }, + { + "name": "res-delete", + "description": "Hard-delete records by ID (physical removal, unrecoverable).\rWARNING: Prefer soft delete — use res-update to set deleted_at=CURRENT_TIMESTAMP and enabled=0 instead.\rOnly call res-delete when the user explicitly requests permanent/physical deletion.\rBefore calling, ALWAYS use res-query to show the user the affected records and obtain confirmation.\rPass __confirm__: true after the user has confirmed.", + "inputSchema": { + "type": "object", + "properties": { + "resource": { + "type": "string", + "title": "主资源", + "description": "目标资源编码" + }, + "ids": { + "type": "array", + "title": "记录ID列表", + "description": "必填,指定要删除的记录 ID 列表。必须通过 res-query 预先查询获得,禁止手动构造。", + "items": { + "type": "integer" + } + }, + "__confirm__": { + "type": "boolean", + "title": "确认执行", + "description": "首次调用不传。仅当服务端返回需要确认的提示时,重新调用并传入 true 以执行删除。不可主动传入,否则将跳过安全确认步骤。", + "default": false + } + }, + "required": ["resource", "ids"] + }, + "annotations": {} + }, + { + "name": "res-schema", + "description": "Look up available resources and their field definitions. Without keywords: lists all resources. With keywords: returns matched resources with their field list, types, and constraints. Keywords match against resource code, name, or table name; supports Chinese fuzzy matching; use | to separate multiple terms (e.g. \"用户|权限\"). Call this before using res-query / res-update / res-delete if the resource schema is not already in the current conversation.", + "inputSchema": { + "type": "object", + "properties": { + "keywords": { + "type": "string", + "title": "查找关键字", + "description": "搜索关键词,用于筛选资源列表。支持多个关键词,用 | 分隔。\r对每条资源的编码(code)、名称(name)、数据表名(table)进行匹配,\r支持中文分词模糊匹配,结果按匹配相关度从高到低排序。\r示例:\"用户\" 或 \"用户|权限\"" + } + } + }, + "annotations": {} + } + ] +} \ No newline at end of file diff --git a/base/res_api_param_test_input.json b/base/res_api_param_test_input.json new file mode 100644 index 0000000..e744181 --- /dev/null +++ b/base/res_api_param_test_input.json @@ -0,0 +1,758 @@ +[ + { + "id": 271, + "pid": 0, + "code": "fields", + "name": "查询字段", + "category": "param", + "type": "array", + "desc": "查询字段,不传则返回所有字段", + "isRequired": false, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "" + }, + { + "id": 282, + "pid": 0, + "code": "groupBy", + "name": "分组规则", + "category": "param", + "type": "array", + "desc": "GROUP BY 字段列表,配合 fields 中的聚合字段使用", + "isRequired": false, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "" + }, + { + "id": 287, + "pid": 0, + "code": "orderBy", + "name": "排序规则", + "category": "param", + "type": "array", + "desc": "排序规则列表,多项按顺序依次应用", + "isRequired": false, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "" + }, + { + "id": 299, + "pid": 0, + "code": "relations", + "name": "资源关联", + "category": "param", + "type": "array", + "desc": "关联配置列表,支持 JOIN(inner / left / right)将关联字段合并到主查询,或子查询(hasOne / hasMany)将关联数据聚合为独立 JSON 字段", + "isRequired": false, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "" + }, + { + "id": 258, + "pid": 0, + "code": "conditions", + "name": "树形查询条件", + "category": "param", + "type": "json", + "desc": "WHERE 条件树。\rexprs 每项生成一段比较:[{columnSqlFunc}(]{columnResource}.{column}[)] {operator} {value},同层多项以 type(and/or) 连接;\rchildren 为同结构子节点,递归嵌套实现复杂过滤。", + "isRequired": false, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "" + }, + { + "id": 297, + "pid": 0, + "code": "limit", + "name": "每页条数", + "category": "param", + "type": "integer", + "desc": "返回的最大记录数", + "isRequired": false, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "30" + }, + { + "id": 298, + "pid": 0, + "code": "offset", + "name": "偏移量", + "category": "param", + "type": "integer", + "desc": "跳过的记录数", + "isRequired": false, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "0" + }, + { + "id": 259, + "pid": 258, + "code": "type", + "name": "逻辑连接符", + "category": "param", + "type": "string", + "desc": "当前节点的逻辑运算符,将 exprs 各项与 children 各组以 AND 或 OR 连接", + "isRequired": true, + "constraints": { + "enum": [ + "and", + "or" + ] + }, + "rank": 0, + "ref_id": 0, + "df_val": "and" + }, + { + "id": 261, + "pid": 258, + "code": "children", + "name": "子条件", + "category": "param", + "type": "array", + "desc": "子条件节点,递归结构", + "isRequired": false, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "" + }, + { + "id": 260, + "pid": 258, + "code": "exprs", + "name": "条件表达式列表", + "category": "param", + "type": "array", + "desc": "当前层的叶子比较列表,每项生成一个 column {operator} value 片段,同层多项以 type 连接", + "isRequired": true, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "" + }, + { + "id": 262, + "pid": 260, + "code": "items", + "name": "_items", + "category": "param", + "type": "json", + "desc": "", + "isRequired": false, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "" + }, + { + "id": 312, + "pid": 261, + "code": "items", + "name": "_items", + "category": "param", + "type": "json", + "desc": "", + "isRequired": false, + "constraints": {}, + "rank": 0, + "ref_id": 258, + "df_val": "" + }, + { + "id": 263, + "pid": 262, + "code": "column", + "name": "字段编码", + "category": "param", + "type": "string", + "desc": "", + "isRequired": true, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "" + }, + { + "id": 264, + "pid": 262, + "code": "columnResource", + "name": "所属资源", + "category": "param", + "type": "string", + "desc": "字段所属资源标识,取值为顶层 resource 或 relations[].code", + "isRequired": false, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "" + }, + { + "id": 268, + "pid": 262, + "code": "columnSqlFunc", + "name": "列SQL函数", + "category": "param", + "type": "string", + "desc": "对列应用 SQL 函数后再参与比较,拼接为 {func}({columnResource}.{column}) {operator} {value}。\r\n支持 SQL-92 / SQL:1999 / SQL:2003 标准函数及数据库方言函数,如 DATE、UPPER、ROUND。\r\n需要第二参数的函数(如 ROUND、CAST)配合 columnSqlFuncParam 使用。", + "isRequired": false, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "" + }, + { + "id": 269, + "pid": 262, + "code": "columnSqlFuncParam", + "name": "列SQL函数参数", + "category": "param", + "type": "string", + "desc": "columnSqlFunc 的附加参数,按函数类型用途不同:\r\r通用函数(有 param 时):{func}({columnResource}.{column}, {columnSqlFuncParam}) {operator} {value}\r 示例:ROUND(col, 2) → columnSqlFuncParam=\"2\"\r SUBSTRING(col, 1, 10) → columnSqlFuncParam=\"1, 10\"\r\rJSON 函数(param 为 JSON 路径表达式):\r json_member_of(value 是否是列 JSON 数组的成员):\r 无 param:JSON_CONTAINS({value}, JSON_ARRAY({column}))\r 有 param:JSON_CONTAINS({columnSqlFuncParam}, JSON_ARRAY({column}))\r json_contains(列 JSON 是否包含 value):\r 无 param:JSON_CONTAINS({column}, JSON_ARRAY({value}))\r 有 param:JSON_CONTAINS({columnSqlFuncParam}, JSON_ARRAY({value}))", + "isRequired": false, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "" + }, + { + "id": 265, + "pid": 262, + "code": "operator", + "name": "运算符", + "category": "param", + "type": "string", + "desc": "标准 SQL 比较运算符:\r\n= / != / \u003e / \u003e= / \u003c / \u003c=\r\nLIKE:模糊匹配(%keyword%)\r\nIN:包含,value 逗号分隔多值\r\nIS NULL / IS NOT NULL:空值判断,无需 value\r\nREGEXP:正则匹配", + "isRequired": true, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "=" + }, + { + "id": 266, + "pid": 262, + "code": "value", + "name": "比较值", + "category": "param", + "type": "string", + "desc": "比较目标,内容由 valueType 决定。operator 为 IS NULL/IS NOT NULL 时可省略", + "isRequired": false, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "" + }, + { + "id": 267, + "pid": 262, + "code": "valueType", + "name": "比较值类型", + "category": "param", + "type": "string", + "desc": "先选类型再填 value:\r\nstring:value 填字面量如 '2024-01-01'\r\nfunc:value 填内置函数名 UserID|UserUuid|UserRolesUuid|UserPlatform|UserSaaS\r\nparam:value 填请求参数路径如 body.status\r\nsql:value 填原始SQL如 CURRENT_DATE", + "isRequired": false, + "constraints": { + "enum": [ + "string", + "func" + ] + }, + "rank": 0, + "ref_id": 0, + "df_val": "string" + }, + { + "id": 270, + "pid": 262, + "code": "ignoreEmptyParam", + "name": "忽略空参数", + "category": "param", + "type": "bool", + "desc": "当 valueType=param 且请求参数值为空(空字符串或 null)时,跳过该条件不生成 SQL。用于实现可选过滤项", + "isRequired": false, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "false" + }, + { + "id": 272, + "pid": 271, + "code": "oneOf", + "name": "字段编码", + "category": "param", + "type": "string", + "desc": "字段引用,格式:{columnResource}.{column} 或 {column}", + "isRequired": false, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "" + }, + { + "id": 279, + "pid": 271, + "code": "oneOf", + "name": "表达式字段", + "category": "param", + "type": "json", + "desc": "", + "isRequired": false, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "" + }, + { + "id": 275, + "pid": 271, + "code": "oneOf", + "name": "资源字段", + "category": "param", + "type": "json", + "desc": "", + "isRequired": false, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "" + }, + { + "id": 278, + "pid": 275, + "code": "alias", + "name": "别名", + "category": "param", + "type": "string", + "desc": "", + "isRequired": false, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "" + }, + { + "id": 276, + "pid": 275, + "code": "column", + "name": "字段编码", + "category": "param", + "type": "string", + "desc": "", + "isRequired": true, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "" + }, + { + "id": 277, + "pid": 275, + "code": "columnResource", + "name": "所属资源", + "category": "param", + "type": "string", + "desc": "字段所属资源标识,取值为顶层 resource 或 relations[].code", + "isRequired": false, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "" + }, + { + "id": 281, + "pid": 279, + "code": "alias", + "name": "别名", + "category": "param", + "type": "string", + "desc": "", + "isRequired": false, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "" + }, + { + "id": 280, + "pid": 279, + "code": "expr", + "name": "SQL 表达式", + "category": "param", + "type": "string", + "desc": "SQL 表达式:如 COUNT(*) / SUM(amount)\r{column}:字段编码,默认归属顶层 resource\r{columnResource}.{column}:显式指定资源的字段引用", + "isRequired": false, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "" + }, + { + "id": 283, + "pid": 282, + "code": "oneOf", + "name": "分组表达式", + "category": "param", + "type": "string", + "desc": "SQL 表达式:如 COUNT(*) / SUM(amount)\r{column}:字段编码,默认归属顶层 resource\r{columnResource}.{column}:显式指定资源的字段引用", + "isRequired": true, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "" + }, + { + "id": 284, + "pid": 282, + "code": "oneOf", + "name": "资源字段", + "category": "param", + "type": "json", + "desc": "", + "isRequired": false, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "" + }, + { + "id": 285, + "pid": 284, + "code": "column", + "name": "字段编码", + "category": "param", + "type": "string", + "desc": "", + "isRequired": true, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "" + }, + { + "id": 286, + "pid": 284, + "code": "columnResource", + "name": "所属资源", + "category": "param", + "type": "string", + "desc": "字段所属资源标识,取值为顶层 resource 或 relations[].code", + "isRequired": false, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "" + }, + { + "id": 288, + "pid": 287, + "code": "oneOf", + "name": "SQL 表达式 (ASC)", + "category": "param", + "type": "string", + "desc": "SQL 表达式:如 COUNT(*) / SUM(amount)\r{column}:字段编码,默认归属顶层 resource\r{columnResource}.{column}:显式指定资源的字段引用\r默认升序(asc)", + "isRequired": false, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "" + }, + { + "id": 289, + "pid": 287, + "code": "oneOf", + "name": "SQL 表达式排序", + "category": "param", + "type": "json", + "desc": "", + "isRequired": false, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "" + }, + { + "id": 292, + "pid": 287, + "code": "oneOf", + "name": "资源字段排序", + "category": "param", + "type": "json", + "desc": "", + "isRequired": false, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "" + }, + { + "id": 291, + "pid": 289, + "code": "direction", + "name": "排序方向", + "category": "param", + "type": "string", + "desc": "", + "isRequired": false, + "constraints": { + "enum": [ + "asc", + "desc" + ] + }, + "rank": 0, + "ref_id": 0, + "df_val": "asc" + }, + { + "id": 290, + "pid": 289, + "code": "expr", + "name": "SQL 表达式", + "category": "param", + "type": "string", + "desc": "SQL 表达式:如 COUNT(*) / SUM(amount)\r{column}:字段编码,默认归属顶层 resource\r{columnResource}.{column}:显式指定资源的字段引用", + "isRequired": false, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "" + }, + { + "id": 293, + "pid": 292, + "code": "column", + "name": "字段编码", + "category": "param", + "type": "string", + "desc": "", + "isRequired": true, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "" + }, + { + "id": 295, + "pid": 292, + "code": "columnResource", + "name": "所属资源", + "category": "param", + "type": "string", + "desc": "", + "isRequired": true, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "" + }, + { + "id": 296, + "pid": 292, + "code": "direction", + "name": "排序方向", + "category": "param", + "type": "string", + "desc": "", + "isRequired": false, + "constraints": { + "enum": [ + "asc", + "desc" + ] + }, + "rank": 0, + "ref_id": 0, + "df_val": "asc" + }, + { + "id": 300, + "pid": 299, + "code": "items", + "name": "", + "category": "param", + "type": "json", + "desc": "", + "isRequired": false, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "" + }, + { + "id": 305, + "pid": 300, + "code": "actuallyField", + "name": "实际资源字段", + "category": "param", + "type": "string", + "desc": "目标资源(code)参与 ON 条件的字段", + "isRequired": true, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "" + }, + { + "id": 304, + "pid": 300, + "code": "actuallyResource", + "name": "实际资源", + "category": "param", + "type": "string", + "desc": "被关联的目标资源编码(相当于 SQL 表名)。JOIN 拼接为:{type} JOIN {actuallyResource} AS {code} ON {code}.{actuallyField} = {relationResource}.{relationField}", + "isRequired": true, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "" + }, + { + "id": 303, + "pid": 300, + "code": "code", + "name": "关联标识", + "category": "param", + "type": "string", + "desc": "关联唯一标识,同时作为别名:JOIN 时为表别名(AS code),子查询时为结果 JSON 字段的键名", + "isRequired": true, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "" + }, + { + "id": 302, + "pid": 300, + "code": "name", + "name": "关联名称", + "category": "param", + "type": "string", + "desc": "关联的显示名称", + "isRequired": false, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "" + }, + { + "id": 307, + "pid": 300, + "code": "relationField", + "name": "被关联资源字段", + "category": "param", + "type": "string", + "desc": "ON 条件对端资源的字段", + "isRequired": true, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "" + }, + { + "id": 306, + "pid": 300, + "code": "relationResource", + "name": "被关联资源", + "category": "param", + "type": "string", + "desc": "ON 条件对端的资源编码(通常为主资源或父关联资源)", + "isRequired": true, + "constraints": {}, + "rank": 0, + "ref_id": 0, + "df_val": "" + }, + { + "id": 301, + "pid": 300, + "code": "type", + "name": "关联类型", + "category": "param", + "type": "string", + "desc": "关联模式:\r\ninner / left / right:SQL JOIN,将关联资源字段合并到主查询行\r\nhasOne:子查询,关联结果聚合为单个对象(result[code] = {})\r\nhasMany:子查询,关联结果聚合为对象数组(result[code] = [...])", + "isRequired": true, + "constraints": { + "enum": [ + "inner", + "left", + "right", + "hasOne", + "hasMany" + ] + }, + "rank": 0, + "ref_id": 0, + "df_val": "" + }, + { + "id": 309, + "pid": 300, + "code": "groupBy", + "name": "关联分组,仅 hasOne/hasMany 有效", + "category": "param", + "type": "array", + "desc": "", + "isRequired": false, + "constraints": {}, + "rank": 0, + "ref_id": 282, + "df_val": "" + }, + { + "id": 310, + "pid": 300, + "code": "orderBy", + "name": "关联排序,仅 hasOne/hasMany 有效", + "category": "param", + "type": "array", + "desc": "", + "isRequired": false, + "constraints": {}, + "rank": 0, + "ref_id": 287, + "df_val": "" + }, + { + "id": 308, + "pid": 300, + "code": "conditions", + "name": "关联的过滤条件", + "category": "param", + "type": "json", + "desc": "", + "isRequired": false, + "constraints": {}, + "rank": 0, + "ref_id": 258, + "df_val": "" + }, + { + "id": 311, + "pid": 300, + "code": "limit", + "name": "关联条数限制,仅 hasOne/hasMany 有效", + "category": "param", + "type": "integer", + "desc": "", + "isRequired": false, + "constraints": {}, + "rank": 0, + "ref_id": 297, + "df_val": "" + } +] \ No newline at end of file diff --git a/base/res_api_param_test_output.json b/base/res_api_param_test_output.json new file mode 100644 index 0000000..b4f7cfb --- /dev/null +++ b/base/res_api_param_test_output.json @@ -0,0 +1,324 @@ +{ + "type": "object", + "properties": { + "conditions": { + "type": "object", + "properties": { + "children": { + "type": "array", + "items": { + "$ref": "#/properties/conditions" + }, + "title": "子条件", + "description": "子条件节点,递归结构" + }, + "exprs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "column": { + "type": "string", + "title": "字段编码" + }, + "columnResource": { + "type": "string", + "title": "所属资源", + "description": "字段所属资源标识,取值为顶层 resource 或 relations[].code" + }, + "columnSqlFunc": { + "type": "string", + "title": "列SQL函数", + "description": "对列应用 SQL 函数后再参与比较,拼接为 {func}({columnResource}.{column}) {operator} {value}。\r\n支持 SQL-92 / SQL:1999 / SQL:2003 标准函数及数据库方言函数,如 DATE、UPPER、ROUND。\r\n需要第二参数的函数(如 ROUND、CAST)配合 columnSqlFuncParam 使用。" + }, + "columnSqlFuncParam": { + "type": "string", + "title": "列SQL函数参数", + "description": "columnSqlFunc 的附加参数,按函数类型用途不同:\r\r通用函数(有 param 时):{func}({columnResource}.{column}, {columnSqlFuncParam}) {operator} {value}\r 示例:ROUND(col, 2) → columnSqlFuncParam=\"2\"\r SUBSTRING(col, 1, 10) → columnSqlFuncParam=\"1, 10\"\r\rJSON 函数(param 为 JSON 路径表达式):\r json_member_of(value 是否是列 JSON 数组的成员):\r 无 param:JSON_CONTAINS({value}, JSON_ARRAY({column}))\r 有 param:JSON_CONTAINS({columnSqlFuncParam}, JSON_ARRAY({column}))\r json_contains(列 JSON 是否包含 value):\r 无 param:JSON_CONTAINS({column}, JSON_ARRAY({value}))\r 有 param:JSON_CONTAINS({columnSqlFuncParam}, JSON_ARRAY({value}))" + }, + "ignoreEmptyParam": { + "type": "boolean", + "title": "忽略空参数", + "description": "当 valueType=param 且请求参数值为空(空字符串或 null)时,跳过该条件不生成 SQL。用于实现可选过滤项", + "default": false + }, + "operator": { + "type": "string", + "title": "运算符", + "description": "标准 SQL 比较运算符:\r\n= / != / \u003e / \u003e= / \u003c / \u003c=\r\nLIKE:模糊匹配(%keyword%)\r\nIN:包含,value 逗号分隔多值\r\nIS NULL / IS NOT NULL:空值判断,无需 value\r\nREGEXP:正则匹配", + "default": "=" + }, + "value": { + "type": "string", + "title": "比较值", + "description": "比较目标,内容由 valueType 决定。operator 为 IS NULL/IS NOT NULL 时可省略" + }, + "valueType": { + "type": "string", + "title": "比较值类型", + "description": "先选类型再填 value:\r\nstring:value 填字面量如 '2024-01-01'\r\nfunc:value 填内置函数名 UserID|UserUuid|UserRolesUuid|UserPlatform|UserSaaS\r\nparam:value 填请求参数路径如 body.status\r\nsql:value 填原始SQL如 CURRENT_DATE", + "default": "string", + "enum": [ + "string", + "func" + ] + } + }, + "title": "_items", + "description": "_items", + "required": [ + "column", + "operator" + ] + }, + "title": "条件表达式列表", + "description": "当前层的叶子比较列表,每项生成一个 column {operator} value 片段,同层多项以 type 连接" + }, + "type": { + "type": "string", + "title": "逻辑连接符", + "description": "当前节点的逻辑运算符,将 exprs 各项与 children 各组以 AND 或 OR 连接", + "default": "and", + "enum": [ + "and", + "or" + ] + } + }, + "title": "树形查询条件", + "description": "WHERE 条件树。\rexprs 每项生成一段比较:[{columnSqlFunc}(]{columnResource}.{column}[)] {operator} {value},同层多项以 type(and/or) 连接;\rchildren 为同结构子节点,递归嵌套实现复杂过滤。", + "required": [ + "exprs", + "type" + ] + }, + "fields": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string", + "title": "字段编码", + "description": "字段引用,格式:{columnResource}.{column} 或 {column}" + }, + { + "type": "object", + "properties": { + "alias": { + "type": "string", + "title": "别名" + }, + "expr": { + "type": "string", + "title": "SQL 表达式", + "description": "SQL 表达式:如 COUNT(*) / SUM(amount)\r{column}:字段编码,默认归属顶层 resource\r{columnResource}.{column}:显式指定资源的字段引用" + } + }, + "title": "表达式字段" + }, + { + "type": "object", + "properties": { + "alias": { + "type": "string", + "title": "别名" + }, + "column": { + "type": "string", + "title": "字段编码" + }, + "columnResource": { + "type": "string", + "title": "所属资源", + "description": "字段所属资源标识,取值为顶层 resource 或 relations[].code" + } + }, + "title": "资源字段", + "required": [ + "column" + ] + } + ] + }, + "title": "查询字段", + "description": "查询字段,不传则返回所有字段" + }, + "groupBy": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string", + "title": "分组表达式", + "description": "SQL 表达式:如 COUNT(*) / SUM(amount)\r{column}:字段编码,默认归属顶层 resource\r{columnResource}.{column}:显式指定资源的字段引用" + }, + { + "type": "object", + "properties": { + "column": { + "type": "string", + "title": "字段编码" + }, + "columnResource": { + "type": "string", + "title": "所属资源", + "description": "字段所属资源标识,取值为顶层 resource 或 relations[].code" + } + }, + "title": "资源字段", + "required": [ + "column" + ] + } + ] + }, + "title": "分组规则", + "description": "GROUP BY 字段列表,配合 fields 中的聚合字段使用" + }, + "limit": { + "type": "integer", + "title": "每页条数", + "description": "返回的最大记录数", + "default": 30 + }, + "offset": { + "type": "integer", + "title": "偏移量", + "description": "跳过的记录数", + "default": 0 + }, + "orderBy": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string", + "title": "SQL 表达式 (ASC)", + "description": "SQL 表达式:如 COUNT(*) / SUM(amount)\r{column}:字段编码,默认归属顶层 resource\r{columnResource}.{column}:显式指定资源的字段引用\r默认升序(asc)" + }, + { + "type": "object", + "properties": { + "direction": { + "type": "string", + "title": "排序方向", + "default": "asc", + "enum": [ + "asc", + "desc" + ] + }, + "expr": { + "type": "string", + "title": "SQL 表达式", + "description": "SQL 表达式:如 COUNT(*) / SUM(amount)\r{column}:字段编码,默认归属顶层 resource\r{columnResource}.{column}:显式指定资源的字段引用" + } + }, + "title": "SQL 表达式排序" + }, + { + "type": "object", + "properties": { + "column": { + "type": "string", + "title": "字段编码" + }, + "columnResource": { + "type": "string", + "title": "所属资源" + }, + "direction": { + "type": "string", + "title": "排序方向", + "default": "asc", + "enum": [ + "asc", + "desc" + ] + } + }, + "title": "资源字段排序", + "required": [ + "column", + "columnResource" + ] + } + ] + }, + "title": "排序规则", + "description": "排序规则列表,多项按顺序依次应用" + }, + "relations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "actuallyField": { + "type": "string", + "title": "实际资源字段", + "description": "目标资源(code)参与 ON 条件的字段" + }, + "actuallyResource": { + "type": "string", + "title": "实际资源", + "description": "被关联的目标资源编码(相当于 SQL 表名)。JOIN 拼接为:{type} JOIN {actuallyResource} AS {code} ON {code}.{actuallyField} = {relationResource}.{relationField}" + }, + "code": { + "type": "string", + "title": "关联标识", + "description": "关联唯一标识,同时作为别名:JOIN 时为表别名(AS code),子查询时为结果 JSON 字段的键名" + }, + "conditions": { + "$ref": "#/properties/conditions" + }, + "groupBy": { + "$ref": "#/properties/groupBy" + }, + "limit": { + "$ref": "#/properties/limit" + }, + "name": { + "type": "string", + "title": "关联名称", + "description": "关联的显示名称" + }, + "orderBy": { + "$ref": "#/properties/orderBy" + }, + "relationField": { + "type": "string", + "title": "被关联资源字段", + "description": "ON 条件对端资源的字段" + }, + "relationResource": { + "type": "string", + "title": "被关联资源", + "description": "ON 条件对端的资源编码(通常为主资源或父关联资源)" + }, + "type": { + "type": "string", + "title": "关联类型", + "description": "关联模式:\r\ninner / left / right:SQL JOIN,将关联资源字段合并到主查询行\r\nhasOne:子查询,关联结果聚合为单个对象(result[code] = {})\r\nhasMany:子查询,关联结果聚合为对象数组(result[code] = [...])", + "enum": [ + "inner", + "left", + "right", + "hasOne", + "hasMany" + ] + } + }, + "required": [ + "actuallyField", + "actuallyResource", + "code", + "relationField", + "relationResource", + "type" + ] + }, + "title": "资源关联", + "description": "关联配置列表,支持 JOIN(inner / left / right)将关联字段合并到主查询,或子查询(hasOne / hasMany)将关联数据聚合为独立 JSON 字段" + } + } +} \ No newline at end of file diff --git a/base/res_configure.go b/base/res_configure.go index 4623fa5..9760b7e 100644 --- a/base/res_configure.go +++ b/base/res_configure.go @@ -1,6 +1,8 @@ package base import ( + "encoding/json" + "git.fsdpf.net/go/reflux" "git.fsdpf.net/go/reflux/valuex" ) @@ -30,19 +32,35 @@ type ResConfigure struct { func (rc *ResConfigure) GetValueByRoles(roles ...string) (valuex.Accessor, bool) { // 如果没有角色值配置或没有传入角色,返回默认值 if len(rc.RoleValues) == 0 || len(roles) == 0 { - return reflux.New(rc.Value), false + return reflux.New(rc.toTyped(rc.Value)), true } for _, rv := range rc.RoleValues { for _, role := range roles { if value, exists := rv[role]; exists { - return reflux.New(value), true + return reflux.New(rc.toTyped(value)), true } } } // 没有找到匹配的角色值,返回默认值 - return reflux.New(rc.Value), false + return reflux.New(rc.toTyped(rc.Value)), false +} + +func (rc *ResConfigure) toTyped(s string) any { + switch rc.Type { + case "json": + var v map[string]any + if json.Unmarshal([]byte(s), &v) == nil { + return v + } + case "array": + var v []any + if json.Unmarshal([]byte(s), &v) == nil { + return v + } + } + return s } type GetResConfigure func(key string, roles ...string) (valuex.Accessor, bool) diff --git a/base/res_field.go b/base/res_field.go index a922fcf..ebd5f5f 100644 --- a/base/res_field.go +++ b/base/res_field.go @@ -1,274 +1,9 @@ package base 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" + "git.fsdpf.net/go/req/resx" ) -// ResField 资源字段 -type ResField struct { - Uuid string `db:"uuid" json:"uuid"` - Name string `db:"name" json:"name"` - Code string `db:"code" json:"code"` - CodeResource string `db:"codeResource" json:"codeResource"` - DataType req.ResDataType `db:"data_type" json:"data_type"` - Length string `db:"length" json:"length"` - Comment string `db:"comment" json:"comment"` - Default string `db:"default" json:"default"` - Virtual bool `db:"virtual" json:"virtual"` - VirtualExpr string `db:"virtual_expr" json:"virtual_expr"` -} - -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.Default != "" && this.Default[0:1] == "[" { - typ = reflect.TypeOf([]any{}) - } else { - typ = reflect.TypeOf(map[string]any{}) - } - } - - 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) 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) 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.Default != "" && this.Default[0:1] == "[" { - return req.ReqArray - } - return req.ReqJson - } - return req.ReqString -} - -func (this ResField) ToValue(v any) any { - if this.DataType == req.ResJson { - if v == nil { - if this.Default != "" && this.Default[0:1] == "[" { - return db.V("[]") - } else if this.Default != "" && this.Default[0:1] == "{" { - return db.V("{}") - } else if this.Default == "" { - 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)) - } - } - return v -} - -func (this ResField) GetRawDefault() db.Expression { - if this.DataType == req.ResJson { - if this.Default != "" && this.Default[0:1] == "[" { - return db.V("[]") - } else if this.Default != "" && this.Default[0:1] == "{" { - return db.V("{}") - } else if this.Default == "" { - return db.V("{}") - } - } else if this.DataType == req.ResBoolean { - if v, _ := strconv.ParseBool(this.Default); v { - return db.V(true) - } - return db.V(false) - } - - if len(this.Default) > 4 && strings.ToLower(this.Default[0:4]) == "sql:" { - return db.L(this.Default[4:]) - } - - if this.Default == "" { - if this.GetDataType() == req.ResDate || this.GetDataType() == req.ResDatetime { - return db.V(nil) - } - return db.V("") - } - - if strings.ToUpper(this.Default) == "CURRENT_TIMESTAMP" { - return db.L(this.Default) - } - - return db.V(this.Default) -} - -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.Default) - - 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.Default) == "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) -} +// ResField 的实现搬到了 git.fsdpf.net/go/req/resx(resx 自己需要用它做字段级脱敏, +// 不能反过来 import contracts/base,见 resx.ResField 的注释),这里保留类型别名兼容原有引用。 +type ResField = resx.ResField diff --git a/base/res_field_test.go b/base/res_field_test.go index b15db40..421ee19 100644 --- a/base/res_field_test.go +++ b/base/res_field_test.go @@ -5,6 +5,7 @@ import ( "git.fsdpf.net/go/db/schema" "git.fsdpf.net/go/req" + "git.fsdpf.net/go/req/resx" _ "git.fsdpf.net/go/db/schema/dialect/mysql" _ "git.fsdpf.net/go/db/schema/dialect/sqlite3" @@ -14,16 +15,7 @@ func TestResFieldToBlueprint(t *testing.T) { t.Run("id", func(t *testing.T) { table := schema.NewBlueprint("users") - id := ResField{ - Uuid: "00000000-0000-0000-0000-000000000000", - Name: "ID", - Code: "id", - CodeResource: "User", - DataType: req.ResInteger, - Length: "20", - Comment: "", - Default: "sql:AUTO_INCREMENT", - } + id := resx.NewResField("id", "User", resx.FieldWithUuid("00000000-0000-0000-0000-000000000000"), resx.FieldWithName("ID"), resx.FieldWithLength("20"), resx.FieldWithDefault("sql:AUTO_INCREMENT"), resx.FieldWithDataType(req.ResInteger)) id.ToBlueprint(table) }) diff --git a/base/res_job.go b/base/res_job.go index a04f39d..223afc2 100644 --- a/base/res_job.go +++ b/base/res_job.go @@ -1,14 +1,48 @@ package base +import "time" + type ResJob struct { Uuid string `db:"uuid"` Code string `db:"code"` Name string `db:"name"` ResourceUuid string `db:"resource_uuid"` - Replay int64 `db:"replay"` - IsRecord bool `db:"isRecord"` + MaxAttempts int `db:"maxAttempts"` // 最大重试次数 + Concurrent int `db:"concurrent"` // 同类 job 最大并发数,0 表示不限制 + Timeout int `db:"timeout"` // 单次执行超时秒数,0 表示不限制 + Retry int `db:"retry"` // 重试间隔秒数,0 表示不重试,>0 表示固定间隔秒数 UpdatedAt string `db:"updated_at"` CreatedAt string `db:"created_at"` } type GetResJob func(code string) (ResJob, bool) + +const ( + FailedJobStatusResolved = 0 // 已解决(成功完成) + FailedJobStatusPending = 1 // 待重试 + FailedJobStatusExceeded = 2 // 已超出最大重试次数 + FailedJobStatusInterrupted = 3 // shutdown 中断 +) + +type ResFailedJob struct { + ID int64 `db:"id"` + Queue string `db:"queue"` // 投递队列(ResJob.Code) + Payload string `db:"payload"` // JSON + Platform string `db:"platform"` + Saas string `db:"saas"` + TraceId string `db:"trace_id"` + Exception string `db:"exception"` + Attempts int `db:"attempts"` + MaxAttempts int `db:"maxAttempts"` + Status int `db:"status"` + RetryAt *time.Time `db:"retry_at"` + RetriedAt *time.Time `db:"retried_at"` + OwnedUser string `db:"owned_user"` // 触发任务的用户 uuid + CreatedAt string `db:"created_at"` + UpdatedAt string `db:"updated_at"` +} + +// CanRetry 是否还可以重试 +func (j ResFailedJob) CanRetry() bool { + return j.Status == FailedJobStatusPending && j.Attempts < j.MaxAttempts +} diff --git a/base/res_listener.go b/base/res_listener.go deleted file mode 100644 index 8b38c1b..0000000 --- a/base/res_listener.go +++ /dev/null @@ -1,13 +0,0 @@ -package base - -type ResListener struct { - Uuid string `db:"uuid"` - Code string `db:"code"` - Name string `db:"name"` - ResourceUuid string `db:"resource_uuid"` - Event []any `db:"event"` - UpdatedAt string `db:"updated_at"` - CreatedAt string `db:"created_at"` -} - -type GetResListens func(categoryUuid string, event ...string) []ResListener diff --git a/base/res_watcher.go b/base/res_watcher.go new file mode 100644 index 0000000..764905b --- /dev/null +++ b/base/res_watcher.go @@ -0,0 +1,20 @@ +package base + +// ResWatcher 描述一条"资源写操作监听"注册记录:Code 对应一个实现了 contracts.ResWatcher 的处理器, +// ResourceUuids 是它关心的资源(支持 1-n 个) +type ResWatcher struct { + Uuid string `db:"uuid"` + Code string `db:"code"` + Name string `db:"name"` + ResourceUuids []string `db:"resource_uuid"` + OwnedUser string `db:"owned_user"` // Handle 执行时使用的用户身份(同 Cron/Mqtt 约定) + MaxDepth int `db:"max_depth"` // 允许级联触发的最大深度,用于防止 watcher 之间循环触发 + UpdatedAt string `db:"updated_at"` + CreatedAt string `db:"created_at"` +} + +// GetResWatcher 按 code 查询单条 ResWatcher 注册记录 +type GetResWatcher func(code string) (ResWatcher, bool) + +// GetResWatchers 按资源 uuid 反查关心该资源的 ResWatcher 注册记录 +type GetResWatchers func(resourceUuid string) []ResWatcher diff --git a/base/resource.go b/base/resource.go deleted file mode 100644 index 5b88780..0000000 --- a/base/resource.go +++ /dev/null @@ -1,437 +0,0 @@ -package base - -import ( - "database/sql" - "fmt" - "log" - "reflect" - "sync" - "unicode" - - "github.com/samber/do/v2" - "github.com/samber/lo" - - "git.fsdpf.net/go/condition" - "git.fsdpf.net/go/contracts" - "git.fsdpf.net/go/db" - "git.fsdpf.net/go/db/engine" - "git.fsdpf.net/go/db/exec" - "git.fsdpf.net/go/db/exp" - "git.fsdpf.net/go/db/schema" - "git.fsdpf.net/go/req" -) - -// ResChangeEventTopic 资源变更事件 -const ResChangeEventTopic = "res-change-event-topic" - -// ResChangeRecordTopic 资源变更数据 -const ResChangeRecordTopic = "res-change-record-topic" - -type ResChangeEventTopicPayload struct { - Type string // insert | delete | update - Res Resource // 变更资源 - Result sql.Result // 执行结果 -} - -type ResChangeRecordTopicPayload struct { - Type string // insert | delete | update - User req.User // 操作用户 - Res Resource // 变更资源 - Result sql.Result // 执行结果 - Old []map[string]any // 旧数据 - New []map[string]any // 新数据 -} - -// ResVirtualTableSetup 由 framework-v2 在 sqlite_vtable build tag 下注册到容器。 -// resource.DB() 通过 do.Invoke 取出后调用,完成 vtab 模块注册和 CREATE VIRTUAL TABLE。 -// res 提供 Code / Table / Fields 信息,vt 是 ResVirtualTable 的具体实现。 -type ResVirtualTableSetup func(res Resource) error - -// Resource 资源 -type Resource struct { - container do.Injector - initOnce *sync.Once - - Uuid string `db:"uuid"` - PUuid string `db:"pUuid"` - Code string `db:"code"` - Name string `db:"name"` - Conn string `db:"conn"` - IsResVirtual bool `db:"isVirtual"` - Table string `db:"table"` - Primarykey string `db:"primaryKey"` - HistoryRoles []string `db:"historyRoles"` - Fields []ResField `db:"fields"` - Roles map[string]any `db:"roles"` - UpdatedAt string `db:"updated_at"` - CreatedAt string `db:"created_at"` -} - -func (this *Resource) InitContainer(container do.Injector) { - this.container = container - this.initOnce = &sync.Once{} -} - -// GetUuid 资源UUID -func (this Resource) GetUuid() string { - return this.Uuid -} - -// GetCode 资源CODE -func (this Resource) GetCode() string { - return this.Code -} - -// GetName 资源名 -func (this Resource) GetName() string { - return this.Name -} - -// GetConn 数据库连接 -func (this Resource) GetConn() string { - return this.Conn -} - -// GetPrimarykey 主键 -func (this Resource) GetPrimarykey() string { - return this.Primarykey -} - -// IsVirtual 是否虚拟资源 -func (this Resource) IsVirtual() bool { - return this.IsResVirtual -} - -// IsSystem 是否系统资源 -func (this Resource) IsSystem() bool { - return this.Conn == "service-support" -} - -// GetFields 资源字段 -func (this Resource) GetFields() (result []req.ResField) { - for _, item := range this.Fields { - result = append(result, item) - } - return result -} - -// GetField 资源字段 -func (this Resource) GetField(code string) (req.ResField, bool) { - return lo.Find(this.GetFields(), func(v req.ResField) bool { - return v.GetCode() == code - }) -} - -// HasField 判断资源字段 -func (this Resource) HasField(code string) bool { - return lo.SomeBy(this.GetFields(), func(v req.ResField) bool { - return v.GetCode() == code - }) -} - -// BeginTransaction 开启事物 -func (this Resource) BeginTransaction() (*db.TxDatabase, error) { - return this.DB().Begin() -} - -// DB 获取资源链接 -func (this Resource) DB() *db.Database { - dbEngine := do.MustInvoke[engine.Engine](this.container) - conn := dbEngine.Connection(this.Conn) - - if isLocalDB(conn.Dialect()) && !this.IsResVirtual && this.Table != "" && this.initOnce != nil { - this.initOnce.Do(func() { - if err := this.autoCreateTable(conn); err != nil { - panic(err) - } - }) - } - - // vtable 资源:查询走 engine 中的 :memory: 连接("__"+Conn), - // 原文件连接仅用于 _vtab_cache 持久化,两者互不阻塞。 - if conn.Dialect() == "vtable" && this.Table != "" && this.initOnce != nil { - memConn := dbEngine.Connection("__" + this.Conn) - this.initOnce.Do(func() { - if err := this.autoSetupVtab(memConn); err != nil { - panic(err) - } - }) - return memConn - } - - return conn -} - -// autoSetupVtab 通过容器获取 ResVirtualTableSetup(由 framework-v2 在 sqlite_vtable tag 下注册), -// 再从 App 取出当前资源对应的 ResVirtualTable 实现,完成 vtab 注册和 CREATE VIRTUAL TABLE。 -func (this Resource) autoSetupVtab(conn *db.Database) error { - _, err := conn.Exec(fmt.Sprintf(`CREATE VIRTUAL TABLE IF NOT EXISTS %s USING %s_mod()`, this.Table, this.Table)) - return err -} - -// autoCreateTable 自动创建数据表 -func (this Resource) autoCreateTable(conn *db.Database) error { - sb := schema.New(conn) - return sb.Create(this.Table, func(bp *schema.Blueprint) { - bp.Comment = this.Name - for _, field := range this.Fields { - field.ToBlueprint(bp) - } - }) -} - -// GetTable 获取资源对应的数据表 -func (this Resource) GetTable() exp.LiteralExpression { - if this.IsResVirtual { - return db.L("(" + this.Table + ")") - } - return db.V(db.T(this.Table)) -} - -// GetDBTable 获取资源对应的数据库连接 -func (this Resource) GetDBTable(u req.User, opts ...req.ResOption) (sd *db.SelectDataset) { - if u == nil { - panic("GetDBTable: user cannot be nil") - } - - op := &req.ResOptions{} - - for _, cb := range opts { - cb(op) - } - - alias := this.GetCode() - - if op.Alias != "" { - alias = op.Alias - } - - sd = this.DB().From(this.GetTable().As(alias)) - - if op.Tx != nil { - sd = op.Tx.From(this.GetTable().As(alias)) - } - - sd.WithHook(&ResourceHooks{res: this, u: u, tx: op.Tx}) - - return sd -} - -func (this Resource) GetRolesCondition(u req.User) (*db.SelectDataset, db.Expression) { - isFullRight := false - isFullNot := false - - if u == nil { - u = GetAnonymous() - } - - // 如果是系统用户, 则不做校验 - if u.Uuid() == "ffffffff-ffff-ffff-ffff-ffffffffffff" { - return nil, nil - } - // 这里不能放在 u.Uuid() == "ffffffff-ffff-ffff-ffff-ffffffffffff" 前面 - // 会导致初始化数据时循环依赖 - GetResRoles, err := do.Invoke[GetResRoles](this.container) - - if err != nil { - return nil, nil - } - - items := GetResRoles(this.GetUuid(), u.Roles()...) - - if len(items) == 0 { - return nil, nil - } - - GetResource := do.MustInvoke[contracts.GetResource](this.container) - NewOrm := do.MustInvoke[contracts.NewOrm](this.container) - NewOrmModel := do.MustInvoke[contracts.NewOrmModel](this.container) - NewOrmJoin := do.MustInvoke[contracts.NewOrmJoin](this.container) - - GetResRelationResource := do.MustInvoke[GetResRelationResource](this.container) - GetResRelations := do.MustInvoke[GetResRelations](this.container) - GetOrmConditions := do.MustInvoke[contracts.GetOrmConditions](this.container) - - sub := lo.Reduce(items, func(carry *db.SelectDataset, item ResRole, _ int) *db.SelectDataset { - sd := this.DB().From(this.GetTable().As(this.GetCode())).Select(db.T(this.GetCode()).All()) - - joins := lo.Filter(GetResRelations(item.Uuid), func(item ResRelation, _ int) bool { - return item.Type == "inner" || item.Type == "left" || item.Type == "right" - }) - - for i := 0; i < len(joins); i++ { - oResource, ok := GetResource(joins[i].ResourceCode) - if !ok { - continue - } - rResource, ok := GetResRelationResource(joins[i]) - if !ok { - continue - } - - join := NewOrmJoin(contracts.RelationType(joins[i].Type), oResource, joins[i].Code, joins[i].RelationResource, joins[i].RelationField, joins[i].RelationForeignKey) - // 关联扩展条件 - join.SetCondition(GetOrmConditions(joins[i].Uuid, condition.Describe("关联扩展条件"))) - - join.Inject(sd, NewOrmModel(rResource, rResource.GetCode(), rResource.GetName())) - } - - conditions := GetOrmConditions(item.Uuid, condition.Describe("关联扩展条件")) - - if len(joins) == 0 && conditions.IsEmpty() { - // 无权限, 直接跳过这个 unoin 语句 - if carry != nil { - return carry - } - // 第一个无权限除外, 避免所有用户所属角色都是无权限 - sd = sd.Where(db.V(false)) - isFullNot = true - } else if len(joins) == 0 && conditions.IsNotEmpty() && conditions.IsAlwaysRight() /* 1=1 的这种条件*/ { - // 只要有1个满权限, 直接返回单条语句 - isFullRight = true - return sd - } else if conditions.IsNotEmpty() { - oOrm := NewOrm(this, nil) - oOrm.SetGlobalParams(req.NewGlobalParams("{}", u)) - - sd = sd.Where(conditions.ToSql(oOrm.GetModel())) - - // 如果前面是无权限的sql查看, 这直接返回本次查询 - if isFullNot { - isFullNot = false - return sd - } - } - - if carry != nil { - return carry.Union(sd) - } - - return sd - }, nil) - - if isFullRight { - return nil, nil - } - - if isFullNot { - return nil, db.V(false) - } - - return sub, nil -} - -func (this Resource) GetStruct(extends ...reflect.StructField) any { - fields := []reflect.StructField{} - - for _, field := range this.Fields { - if unicode.IsLetter(rune(field.Code[0])) { - fields = append(fields, field.ToStructField()) - } else { - log.Printf("资源字段错误, 必须以字母开头 <- %s", field.Code) - } - } - - fields = lo.UniqBy(append(fields, extends...), func(v reflect.StructField) string { - return v.Name - }) - - t := reflect.StructOf(fields) - - return reflect.New(t).Interface() -} - -func (this Resource) GetSliceStruct(extends ...reflect.StructField) any { - t := reflect.TypeOf(this.GetStruct(extends...)) - - st := reflect.SliceOf(t.Elem()) - - return reflect.New(st).Interface() -} - -// onResEvent 资源事件 -func (this Resource) onResEvent(t string, result sql.Result) { - if num, err := result.RowsAffected(); num == 0 || err != nil { - return - } - - // 有些地方可能没有注册 contracts.Queue - if qu, err := do.Invoke[contracts.Queue](this.container); err == nil { - // 全局触发器 - // 1. 清除系统缓存 - if err := qu.Publish(ResChangeEventTopic, ResChangeEventTopicPayload{ - Type: t, - Res: this, - Result: result, - }); err != nil { - log.Println("Queue Publish Err:", ResChangeEventTopic, err) - } - } -} - -// onUserEvent 用户事件 -func (this Resource) onUserEvent(builder exec.QueryExecutor, user req.User) { - // old := []map[string]any{} - - // builder.Before(func(b *db.Builder, t string, data ...map[string]any) { - // if t != db.TYPE_UPDATE && t != db.TYPE_DELETE { - // return - // } - // // 查询保存之前的数据 - // if _, err := b.Get(&old); err != nil { - // panic(err) - // } - // }) - - // builder.After(func(b *db.Builder, t string, result sql.Result, err error, data ...map[string]any) { - // if err != nil || t == db.TYPE_SELECT { - // return - // } else if num, err := result.RowsAffected(); num == 0 || err != nil { - // return - // } - - // if user == nil { - // user = GetAnonymous() - // } - - // // 触发消息队列 - // if err := do.MustInvoke[contracts.Queue](this.container).Publish(ResChangeRecordTopic, ResChangeRecordTopicPayload{ - // Type: t, - // User: user, - // Res: this, - // Old: old, - // New: data, - // Result: result, - // }); err != nil { - // log.Println("Queue Publish Err:", ResChangeRecordTopic, err) - // } - // }) -} - -func NewVirtualResource(pRes req.Resource, code, name string, sd *db.SelectDataset, fields []ResField) req.Resource { - fieldsCopy := make([]ResField, len(fields)) - - copy(fieldsCopy, fields) - - for i := 0; i < len(fieldsCopy); i++ { - fieldsCopy[i].CodeResource = code - } - - sql, _, err := sd.WithDialect(pRes.DB().Dialect()).ToSQL() - - if err != nil { - panic(err) - } - - return &Resource{ - Uuid: code, - PUuid: pRes.GetUuid(), - Code: code, - Name: name, - IsResVirtual: true, - Conn: pRes.GetConn(), - Table: sql, - Fields: fieldsCopy, - Roles: nil, - } -} diff --git a/base/resource_hooks.go b/base/resource_hooks.go deleted file mode 100644 index 08302d7..0000000 --- a/base/resource_hooks.go +++ /dev/null @@ -1,426 +0,0 @@ -package base - -import ( - "database/sql" - "encoding/json" - "fmt" - "log" - "reflect" - "strconv" - "strings" - "sync" - - "git.fsdpf.net/go/db" - "git.fsdpf.net/go/db/exp" - "git.fsdpf.net/go/db/schema" - "git.fsdpf.net/go/req" - "github.com/samber/lo" -) - -var changeLogOnce sync.Map // key: Conn,每个数据库连接只建一次 _change_log 表 - -// isLocalDB 判断是否为本地文件型数据库(LastInsertId 返回最后一条而非第一条) -func isLocalDB(dialect string) bool { - return dialect == "sqlite3" || dialect == "duckdb" -} - -type ResourceHooks struct { - res Resource - u req.User - tx *db.TxDatabase - snapshots []map[string]any // UPDATE/DELETE 前预查的行数据 -} - -func (rh *ResourceHooks) isTracked() bool { - if len(rh.res.HistoryRoles) == 0 || rh.res.IsVirtual() { - return false - } - if lo.Contains(rh.res.HistoryRoles, "00000000-0000-0000-0000-000000000000") { - return true - } - return lo.Some(rh.res.HistoryRoles, rh.u.Roles()) -} - -// autoCreateChangeLog 在当前连接上建 _change_log 表,每个连接只执行一次 -func (rh *ResourceHooks) autoCreateChangeLog() error { - conn := rh.res.DB() - v, _ := changeLogOnce.LoadOrStore(rh.res.Conn, &sync.Once{}) - var err error - v.(*sync.Once).Do(func() { - sb := schema.New(conn) - err = sb.Create("_change_log", func(bp *schema.Blueprint) { - bp.Comment = "资源数据变更记录" - bp.BigIncrements("id").AutoIncrement().Comment("ID") - bp.Boolean("enabled").Default("1").Comment("是否有效") - bp.Char("created_user", 36).Default("00000000-0000-0000-0000-000000000000").Comment("操作用户") - bp.Char("owned_user", 36).Default("00000000-0000-0000-0000-000000000000").Comment("拥有者") - bp.Timestamp("created_at").UseCurrent().Comment("操作时间") - bp.Timestamp("updated_at").UseCurrent().Default(db.L("ON UPDATE CURRENT_TIMESTAMP")).Comment("更新时间") - bp.DateTime("deleted_at").Nullable().Comment("删除时间") - bp.Char("resource_uuid", 36).Default("").Comment("资源UUID") - bp.String("category", 10).Default("").Comment("操作类型 INSERT/UPDATE/DELETE") - bp.Char("trace_id", 36).Default("").Comment("请求追踪ID") - bp.Integer("row_id").Default("0").Comment("受影响行ID") - bp.Json("snapshot").Nullable().Comment("操作前数据快照") - }) - }) - return err -} - -// captureSnapshot 在写操作执行前,查出受影响的行存入 snapshots -func (rh *ResourceHooks) captureSnapshot(where exp.ExpressionList) { - sd := rh.res.DB().From(rh.res.GetTable()) - if where != nil && len(where.Expressions()) > 0 { - sd = sd.Where(where.Expressions()...) - } - rows, err := sd.Executor().GetRecords() - if err != nil { - log.Printf("_change_log captureSnapshot err: %v", err) - return - } - rh.snapshots = rows -} - -// writeChangeLogs 将变更写入 _change_log -func (rh *ResourceHooks) writeChangeLogs(category string, result sql.Result) { - if err := rh.autoCreateChangeLog(); err != nil { - log.Printf("_change_log init err: %v", err) - return - } - - traceId := rh.u.Runtime().TraceId() - opUser := rh.u.Uuid() - - var rows []any - - switch category { - case "INSERT": - lastId, _ := result.LastInsertId() - count, _ := result.RowsAffected() - for i := int64(0); i < count; i++ { - rowId := lastId + i // MySQL: lastId 是第一条 - if isLocalDB(rh.res.DB().Dialect()) { - rowId = lastId - count + 1 + i // SQLite: lastId 是最后一条 - } - rows = append(rows, map[string]any{ - "enabled": true, - "created_user": opUser, - "owned_user": opUser, - "resource_uuid": rh.res.Uuid, - "category": category, - "trace_id": traceId, - "row_id": rowId, - "snapshot": nil, - }) - } - - case "UPDATE", "DELETE": - for _, snap := range rh.snapshots { - rowId := int64(0) - if id, ok := snap["id"]; ok { - rowId = toChangeLogInt64(id) - } - snapJSON, _ := json.Marshal(snap) - rows = append(rows, map[string]any{ - "enabled": true, - "created_user": opUser, - "owned_user": opUser, - "resource_uuid": rh.res.Uuid, - "category": category, - "trace_id": traceId, - "row_id": rowId, - "snapshot": string(snapJSON), - }) - } - } - - if len(rows) == 0 { - return - } - inserter := rh.res.DB().Insert("_change_log") - if rh.tx != nil { - inserter = rh.tx.Insert("_change_log") - } - if _, err := inserter.Rows(rows...).Executor().Exec(); err != nil { - log.Printf("_change_log write err: %v", err) - } -} - -func toChangeLogInt64(v any) int64 { - switch val := v.(type) { - case int64: - return val - case int: - return int64(val) - case int32: - return int64(val) - case float64: - return int64(val) - case string: - n, _ := strconv.ParseInt(val, 10, 64) - return n - } - return 0 -} - -func (rh *ResourceHooks) Before(dataset interface{}) error { - switch d := dataset.(type) { - case *db.SelectDataset: - return rh.beforeSelectDataset(d) - case *db.InsertDataset: - return rh.beforeInsertDataset(d) - case *db.UpdateDataset: - return rh.beforeUpdateDataset(d) - case *db.DeleteDataset: - return rh.beforeDeleteDataset(d) - } - return nil -} - -func (rh *ResourceHooks) After(dataset interface{}, result interface{}) error { - if rh.res.IsVirtual() { - // 返回鉴权后的 DB Builder - return nil - } - - // 用户事件 - // if rh.res.IsHistoryRecord { - // rh.res.onUserEvent(builder, user) - // } - - switch dataset.(type) { - case *db.SelectDataset: - case *db.InsertDataset: - r := result.(sql.Result) - rh.res.onResEvent("INSERT", r) - if rh.isTracked() { - rh.writeChangeLogs("INSERT", r) - } - case *db.UpdateDataset: - r := result.(sql.Result) - rh.res.onResEvent("UPDATE", r) - if rh.isTracked() { - rh.writeChangeLogs("UPDATE", r) - } - case *db.DeleteDataset: - r := result.(sql.Result) - rh.res.onResEvent("DELETE", r) - if rh.isTracked() { - rh.writeChangeLogs("DELETE", r) - } - } - - return nil -} - -func (rh *ResourceHooks) beforeInsertDataset(id *db.InsertDataset) error { - switch true { - case id.GetClauses().HasRows(): - return rh.beforeInsertRows(id) - case id.GetClauses().HasVals(): - return rh.beforeInsertColsVals(id) - case id.GetClauses().HasFrom(): - return rh.beforeInsertFromQuery(id) - } - return nil -} - -func (rh *ResourceHooks) beforeInsertRows(id *db.InsertDataset) error { - rows := id.GetClauses().Rows() - - for i := range rows { - rowValue := reflect.ValueOf(rows[i]) - if rowValue.Kind() == reflect.Ptr { - rowValue = rowValue.Elem() - } - if rowValue.Kind() == reflect.Struct { - if row, err := exp.NewRecordFromStruct(rowValue.Interface(), true, false); err != nil { - return err - } else { - rows[i] = row - } - } - } - - for i := range rows { - row := rows[i].(map[string]any) - // 格式化保存数据 - if err := rh.normalizeSaveValue(row); err != nil { - return err - } - // 填充默认数据 - if err := rh.applyDefaultValue(row, true, false); err != nil { - return err - } - } - - return nil -} - -func (rh *ResourceHooks) beforeInsertColsVals(id *db.InsertDataset) error { - cols := id.GetClauses().Cols() - vals := id.GetClauses().Vals() - - colsName := []string{} - for _, col := range cols.Columns() { - colsName = append(colsName, col.(exp.IdentifierExpression).GetCol().(string)) - } - - colsVal := []any{} - for k, v := range lo.OmitByKeys(rh.getFieldsDefaultValue(true, false), colsName) { - cols = cols.Append(db.C(k)) - colsVal = append(colsVal, v) - } - - for i := 0; i < len(vals); i++ { - for j := 0; j < len(vals[i]); j++ { - if filed, ok := rh.res.GetField(colsName[j]); ok { - vals[i][j] = filed.ToValue(vals[i][j]) - } - } - vals[i] = append(vals[i], colsVal...) - } - - *id = *id.Cols(cols) - return nil -} - -func (rh *ResourceHooks) beforeInsertFromQuery(id *db.InsertDataset) error { - return nil -} - -// 规范化保存数据 -// 1. 移除系统缺省字段, 如果是 insert 需要调用 applyDefaultValue -// 2. 移除系统中没有的字段 -func (rh *ResourceHooks) normalizeSaveValue(row db.Record) error { - for k, v := range row { - if k == "id" || k == "created_user" || k == "created_at" || k == "deleted_at" || k == "updated_at" { - delete(row, k) - } else if val, ok := v.(db.Expression); ok { - row[k] = val - } else if field, ok := rh.res.GetField(k); ok { - row[k] = field.ToValue(v) - } else { - delete(row, k) - } - } - return nil -} - -// 填充默认数据 -func (rh *ResourceHooks) applyDefaultValue(row db.Record, forInsert, forUpdate bool) error { - // 填充默认数据 - for k, v := range rh.getFieldsDefaultValue(forInsert, forUpdate) { - if _, ok := row[k]; !ok { - row[k] = v - } - } - return nil -} - -func (rh *ResourceHooks) getFieldsDefaultValue(forInsert, forUpdate bool) map[string]db.Expression { - defs := map[string]db.Expression{} - - if forInsert { - for _, item := range rh.res.Fields { - if item.GetCode() == "updated_at" || item.GetCode() == "created_user" || item.GetCode() == "owned_user" { - continue - } - if len(item.Default) > 4 && strings.ToLower(item.Default[0:4]) == "sql:" { - defs[item.GetCode()] = item.GetRawDefault() - } else if item.DataType == req.ResJson { - defs[item.GetCode()] = item.GetRawDefault() - } - } - - if _, ok := defs["owned_user"]; !ok { - defs["owned_user"] = db.V(rh.u.Uuid()) - } - - defs["created_user"] = db.V(rh.u.Uuid()) - } - - if forUpdate && isLocalDB(rh.res.DB().Dialect()) { - defs["updated_at"] = db.L("CURRENT_TIMESTAMP") - } - - return defs -} - -func (rh *ResourceHooks) beforeSelectDataset(sd *db.SelectDataset) error { - sub, ex := rh.res.GetRolesCondition(rh.u) - if ex != nil { - *sd = *sd.Where(ex) - } else if sub != nil { - table := sd.GetClauses().From().Columns()[0] - if alias, ok := table.(exp.AliasedExpression); ok { - *sd = *sd.From(db.V(sub.Expression()).As(alias.GetAs())) - } else { - *sd = *sd.From(db.V(sub.Expression()).As(table)) - } - } - return nil -} - -func (rh *ResourceHooks) beforeUpdateDataset(ud *db.UpdateDataset) error { - // 虚拟资源暂时不考虑鉴权 - if rh.res.IsVirtual() { - return nil - } - sub, ex := rh.res.GetRolesCondition(rh.u) - if ex != nil { - *ud = *ud.Where(ex) - } else if sub != nil { - table := ud.GetClauses().Table() - if alias, ok := table.(exp.AliasedExpression); ok { - *ud = *ud.Where(alias.GetAs().Col("id").In(sub.Select(db.T(rh.res.GetCode()).Col("id")))) - } else { - *ud = *ud.Where(db.L("?.id", table).In(sub.Select(db.T(rh.res.GetCode()).Col("id")))) - } - } - - // 记录变更前快照(role 条件已应用,WHERE 与实际 UPDATE 一致) - if rh.isTracked() { - rh.captureSnapshot(ud.GetClauses().Where()) - } - - // 格式化保存数据 - if ud.GetClauses().HasSetValues() { - udv := ud.GetClauses().SetValues() - - switch data := udv.(type) { - case map[string]any: - return rh.normalizeSaveValue(data) - case db.Record: - return rh.normalizeSaveValue(data) - default: - return fmt.Errorf("unsupported type: %T", udv) - } - } - - return nil -} -func (rh *ResourceHooks) beforeDeleteDataset(dd *db.DeleteDataset) error { - // 虚拟资源暂时不考虑鉴权 - if rh.res.IsVirtual() { - return nil - } - sub, ex := rh.res.GetRolesCondition(rh.u) - if ex != nil { - *dd = *dd.Where(ex) - } else if sub != nil { - table := dd.GetClauses().From().Columns()[0] - if alias, ok := table.(exp.AliasedExpression); ok { - *dd = *dd.Where(alias.GetAs().Col("id").In(sub.Select(db.T(rh.res.GetCode()).Col("id")))) - } else { - *dd = *dd.Where(db.L("?.id", table).In(sub.Select(db.T(rh.res.GetCode()).Col("id")))) - } - } - - // 记录变更前快照(role 条件已应用,WHERE 与实际 DELETE 一致) - if rh.isTracked() { - rh.captureSnapshot(dd.GetClauses().Where()) - } - - return nil -} diff --git a/base/resource_test.go b/base/resource_test.go deleted file mode 100644 index 4c68025..0000000 --- a/base/resource_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package base_test - -import ( - "os" - "testing" - - "git.fsdpf.net/go/contracts/base" - "git.fsdpf.net/go/db" - "git.fsdpf.net/go/db/engine" - "git.fsdpf.net/go/req" - "github.com/samber/do/v2" - "github.com/stretchr/testify/suite" - - _ "git.fsdpf.net/go/db/schema/dialect/mysql" - _ "git.fsdpf.net/go/db/schema/dialect/sqlite3" -) - -type resourceTest struct { - suite.Suite - res *base.Resource -} - -func TestResource(t *testing.T) { - suite.Run(t, new(resourceTest)) -} - -func (t *resourceTest) SetupSuite() { - app := do.New() - - do.Provide(app, func(container do.Injector) (engine.Engine, error) { - return engine.Open(map[string]engine.DBConfig{ - "default": engine.NewDBConfig("mysql", - engine.WithHost(os.Getenv("MYSQL_HOST")), - engine.WithPort(os.Getenv("MYSQL_PORT")), - engine.WithDatabase(os.Getenv("MYSQL_DB")), - engine.WithUsername(os.Getenv("MYSQL_USER")), - engine.WithPassword(os.Getenv("MYSQL_PASSWD")), - ), - }), nil - }) - - fields := []base.ResField{ - {Name: "字段", Code: "fields", DataType: req.ResJson, Default: "{}"}, - } - - res := &base.Resource{ - Uuid: "4bf3a311-cbe1-4236-bdda-c64ab04ae9b1", - Code: "User", - Name: "用户", - Table: "users", - Primarykey: "id", - Fields: fields, - } - - res.InitContainer(app) - - t.res = res -} - -func (t *resourceTest) TestInsertRows() { - type Temp struct { - Name string `db:"name"` - Fields map[string]any `db:"fields"` - } - - result := "INSERT INTO `users` AS `User` (`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) - - sql, _, _ = t.res.GetDBTable(base.GetAnonymous()).Insert().Rows( - &Temp{Name: "张三", Fields: map[string]any{"aa": 1}}, - ).Executor().ToSQL() - t.Equal(result, sql) - - 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` AS `User` (`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() {} diff --git a/base/user.go b/base/user.go index e9c8094..496ce72 100644 --- a/base/user.go +++ b/base/user.go @@ -53,36 +53,24 @@ func (this user) Runtime() req.UserRuntime { // GetAnonymous 获取匿名用户 func GetAnonymous(opts ...req.UserRuntimeOption) req.User { - u := user{ + return user{ id: 0, uuid: "00000000-0000-0000-0000-000000000000", username: "anonymous", nickname: "匿名者", roles: []string{"00000000-0000-0000-0000-000000000000"}, - runtime: req.UserRuntime{}, + runtime: req.NewUserRuntime(opts...), } - - for _, opt := range opts { - opt(&u.runtime) - } - - return u } // GetSystemUser 系统用户 func GetSystemUser(opts ...req.UserRuntimeOption) req.User { - u := user{ + return user{ id: -1, uuid: "ffffffff-ffff-ffff-ffff-ffffffffffff", username: "system", nickname: "系统", roles: []string{"ffffffff-ffff-ffff-ffff-ffffffffffff"}, - runtime: req.UserRuntime{}, + runtime: req.NewUserRuntime(opts...), } - - for _, opt := range opts { - opt(&u.runtime) - } - - return u } diff --git a/base/user_asset.go b/base/user_asset.go index 50c8bf0..2ab8484 100644 --- a/base/user_asset.go +++ b/base/user_asset.go @@ -8,6 +8,7 @@ type UserAsset struct { ID int64 `db:"id"` Name string `db:"name"` FileName string `db:"filename"` + FileUrl string `db:"file_url"` File string `db:"file"` Mime string `db:"mime"` Size int64 `db:"size"`