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) } }