重构: base 包资源核心类型迁移到 req/resx,新增 res_watcher/res_api_param

base/resource.go、resource_hooks.go、resource_test.go、query_field.go 删除,ResField 等具体实现搬到 req/resx(见 res_field.go 里的类型别名)。res_listener.go 替换成 res_watcher.go,对应资源变更监听概念改名。新增 res_api_param.go 及配套测试(ResApi 参数建模,给 MCP tool 的 JSON Schema 生成用)。
This commit is contained in:
2026-07-22 09:13:05 +08:00
parent 53169264da
commit 9446571363
20 changed files with 2882 additions and 1427 deletions
+446
View File
@@ -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
stringvalue 填字面量如 '2024-01-01'
funcvalue 填内置函数名 UserID|UserUuid|UserRolesUuid|UserPlatform|UserSaaS
paramvalue 填请求参数路径如 body.status
sqlvalue 填原始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_ofvalue 是否是列 JSON 数组的成员):
无 paramJSON_CONTAINS({value}, JSON_ARRAY({column}))
有 paramJSON_CONTAINS({columnSqlFuncParam}, JSON_ARRAY({column}))
json_contains(列 JSON 是否包含 value):
无 paramJSON_CONTAINS({column}, JSON_ARRAY({value}))
有 paramJSON_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},
// orderByitems 用 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 / rightSQL 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 / rightSQL 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 是 arrayitems 是 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<object>
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:批量记录(arrayitems 为 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))
}
// 分支1object
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")
}
// 分支2arrayitems 为 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)
}
}