Files
contracts/base/res_api_param_test.go
T
what 9446571363 重构: 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 生成用)。
2026-07-22 09:13:05 +08:00

447 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}