From cdfd8f658e8e636362e13de11196b03349363fd7 Mon Sep 17 00:00:00 2001 From: David Hu Date: Mon, 18 Aug 2025 12:19:22 +0800 Subject: [PATCH] =?UTF-8?q?Fix=20issue=20#3:=20=E8=A7=A3=E5=86=B3=20JSONPa?= =?UTF-8?q?th=20=E5=A4=9A=E5=AD=97=E6=AE=B5=E5=AD=90=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 18 +++++++ README_zh.md | 18 +++++++ example_test.go | 79 ++++++++++++++++++++++++++++ parser.go | 133 +++++++++++++++++++++++++++++++++++++++++++++-- parser_test.go | 70 ++++++++++++++++++++++++- segments.go | 31 +++++++++++ segments_test.go | 111 +++++++++++++++++++++++++++++++++++++++ test_data.json | 33 ++++++++++++ 8 files changed, 487 insertions(+), 6 deletions(-) create mode 100644 test_data.json diff --git a/README.md b/README.md index c005e2e..294e851 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ A complete Go implementation of JSONPath that fully complies with [RFC 9535](htt - Array slices (`[start:end:step]`) - Array wildcards (`[*]`) - Multiple indices (`[1,2,3]`) + - Multiple field names (`['name','age']`) - Filter expressions (`[?(@.price < 10)]`) - Command Line Tool (`jp`) - Beautiful colorized output @@ -45,6 +46,17 @@ A complete Go implementation of JSONPath that fully complies with [RFC 9535](htt - Enhanced documentation clarity - Fixed typos and inconsistencies +### v2.1.0 (Upcoming) +- New Features + - Support for multiple field name extraction (`['name','age']`) + - Enhanced array indexing with mixed field types +- Improvements + - Better error handling for multi-field expressions + - Performance optimizations for complex queries +- Documentation + - Added examples for multi-field extraction + - Updated feature list to include multi-field support + ### v2.0.0 - Complete rewrite with RFC 9535 compliance - Full implementation of JSONPath specification (RFC 9535) @@ -295,6 +307,12 @@ func main() { // Search for books with titles starting with 'S' "$.store.book[*].title.search('^S.*')" +// Extract multiple fields from objects +"$.store.book[*]['author','price']" + +// Extract multiple fields with wildcard +"$.store.book[*]['title','category']" + // Chain multiple functions "$.store.book[?@.price > 10].title.length()" diff --git a/README_zh.md b/README_zh.md index 1193f1b..4a9b961 100644 --- a/README_zh.md +++ b/README_zh.md @@ -19,6 +19,7 @@ - 数组切片(`[start:end:step]`) - 数组通配符(`[*]`) - 多重索引(`[1,2,3]`) + - 多字段名称(`['name','age']`) - 过滤表达式(`[?(@.price < 10)]`) - 命令行工具(`jp`) - 精美的彩色输出 @@ -50,6 +51,17 @@ - 添加更多示例和用例 - 改进 API 文档 +### v2.1.0 (即将发布) +- 新特性 + - 支持多字段名称提取(`['name','age']`) + - 增强的数组索引支持混合字段类型 +- 改进 + - 更好的多字段表达式错误处理 + - 复杂查询的性能优化 +- 文档 + - 添加多字段提取示例 + - 更新特性列表以包含多字段支持 + ### v1.0.4 - 集中管理版本号 @@ -296,6 +308,12 @@ func main() { // 组合搜索和过滤条件 "$.store.book[?@.title.match('^S.*') && @.price < 10].author" + +// 从对象中提取多个字段 +"$.store.book[*]['author','price']" + +// 使用通配符提取多个字段 +"$.store.book[*]['title','category']" ``` ### 结果处理方法 diff --git a/example_test.go b/example_test.go index 941cf25..8f6ec63 100644 --- a/example_test.go +++ b/example_test.go @@ -992,3 +992,82 @@ func TestSearchFunction(t *testing.T) { }) } } + +func TestMultiFieldExtraction(t *testing.T) { + // 测试数据 + jsonData := `{ + "star": { + "name": "Sun", + "diameter": 1391016, + "age": null, + "planets": [ + { + "name": "Mercury", + "Number of Moons": "0", + "diameter": 4879, + "has-moons": false + }, + { + "name": "Venus", + "Number of Moons": "0", + "diameter": 12104, + "has-moons": false + }, + { + "name": "Earth", + "Number of Moons": "1", + "diameter": 12756, + "has-moons": true + }, + { + "name": "Mars", + "Number of Moons": "2", + "diameter": 6792, + "has-moons": true + } + ] + } + }` + + testCases := []struct { + name string + path string + expected interface{} + wantErr bool + }{ + { + name: "extract multiple fields from objects", + path: "$.star.planets.*['name','diameter']", + expected: []interface{}{"Mercury", float64(4879), "Venus", float64(12104), "Earth", float64(12756), "Mars", float64(6792)}, + }, + { + name: "extract multiple fields with wildcard", + path: "$.star.planets[*]['name','has-moons']", + expected: []interface{}{"Mercury", false, "Venus", false, "Earth", true, "Mars", true}, + }, + { + name: "extract single field", + path: "$.star.planets[*]['name']", + expected: []interface{}{"Mercury", "Venus", "Earth", "Mars"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result, err := Query(jsonData, tc.path) + if tc.wantErr { + if err == nil { + t.Errorf("expected error but got none") + } + return + } + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + if !reflect.DeepEqual(result, tc.expected) { + t.Errorf("got %v, want %v", result, tc.expected) + } + }) + } +} diff --git a/parser.go b/parser.go index e3b9a9e..aaa5a18 100644 --- a/parser.go +++ b/parser.go @@ -153,8 +153,10 @@ func parseBracketSegment(content string) (segment, error) { return parseFilterSegment(content[1:]) } - // 处理多索引选择 - if strings.Contains(content, ",") { + // 处理多索引选择或多字段选择 + if strings.Contains(content, ",") || + ((strings.HasPrefix(content, "'") && strings.HasSuffix(content, "'")) && strings.Contains(content[1:len(content)-1], "','")) || + ((strings.HasPrefix(content, "\"") && strings.HasSuffix(content, "\"")) && strings.Contains(content[1:len(content)-1], "\",\"")) { return parseMultiIndexSegment(content) } @@ -649,15 +651,131 @@ func getFieldValue(obj interface{}, field string) (interface{}, error) { return current, nil } +// isValidIdentifier 检查字符串是否是有效的标识符 +func isValidIdentifier(s string) bool { + if s == "" { + return false + } + + // 检查第一个字符是否是字母或下划线 + first := s[0] + if !((first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z') || first == '_') { + return false + } + + // 检查其余字符是否是字母、数字或下划线 + for i := 1; i < len(s); i++ { + c := s[i] + if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_') { + return false + } + } + + return true +} + // 解析多索引选择 func parseMultiIndexSegment(content string) (segment, error) { + // 检查前导和尾随逗号 + if strings.HasPrefix(content, ",") { + return nil, NewError(ErrInvalidPath, "leading comma in multi-index segment", content) + } + if strings.HasSuffix(content, ",") { + return nil, NewError(ErrInvalidPath, "trailing comma in multi-index segment", content) + } + parts := strings.Split(content, ",") - indices := make([]int, 0, len(parts)) + // 检查空索引 + for _, part := range parts { + if strings.TrimSpace(part) == "" { + return nil, NewError(ErrInvalidPath, "empty index in multi-index segment", content) + } + } + + // 检查是否包含字符串字段名 + // 检查是否包含字符串字段名 + hasString := false + hasQuotedString := false for _, part := range parts { - idx, err := strconv.Atoi(strings.TrimSpace(part)) + trimmed := strings.TrimSpace(part) + // 检查是否是字符串字段名(带引号) + if (strings.HasPrefix(trimmed, "'") && strings.HasSuffix(trimmed, "'")) || + (strings.HasPrefix(trimmed, "\"") && strings.HasSuffix(trimmed, "\"")) { + hasString = true + hasQuotedString = true + break + } + // 检查是否是非数字的字段名 + if _, err := strconv.Atoi(trimmed); err != nil { + // 如果不是带引号的字符串,但也不是数字,可能是无效索引或不带引号的字段名 + // 我们需要进一步判断 + hasString = true + break + } + } + + // 如果有引号字符串,或者所有非数字部分都是有效的字段名,则作为多字段段处理 + if hasString && hasQuotedString { + // 有引号字符串,按多字段段处理 + names := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + // 处理带引号的字符串 + if (strings.HasPrefix(trimmed, "'") && strings.HasSuffix(trimmed, "'")) || + (strings.HasPrefix(trimmed, "\"") && strings.HasSuffix(trimmed, "\"")) { + names = append(names, trimmed[1:len(trimmed)-1]) + } else { + // 处理不带引号的字段名 + names = append(names, trimmed) + } + } + return &multiNameSegment{names: names}, nil + } + + // 检查是否所有部分都是有效的字段名(不带引号但也不是纯数字) + if hasString && !hasQuotedString { + // 检查是否所有部分都是数字或者所有部分都是有效的标识符 + allNumbers := true + allValidNames := true + + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if _, err := strconv.Atoi(trimmed); err != nil { + // 不是数字 + allNumbers = false + if !isValidIdentifier(trimmed) { + allValidNames = false + } + } else { + // 是数字,但在混合情况下不应该作为字段名 + allValidNames = false + } + } + + // 如果混合了数字和非数字,这是无效的 + if !allNumbers && !allValidNames { + return nil, NewError(ErrInvalidPath, "cannot mix numeric indices and field names", content) + } + + if allValidNames { + // 所有部分都是有效标识符,按多字段段处理 + names := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + names = append(names, trimmed) + } + return &multiNameSegment{names: names}, nil + } + } + + // 否则解析为多索引段 + indices := make([]int, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + idx, err := strconv.Atoi(trimmed) if err != nil { - return nil, fmt.Errorf("invalid index: %s", part) + return nil, NewError(ErrInvalidPath, fmt.Sprintf("invalid index: %s", trimmed), content) } indices = append(indices, idx) } @@ -724,6 +842,11 @@ func parseIndexOrName(content string) (segment, error) { return &nameSegment{name: content[1 : len(content)-1]}, nil } + // 处理双引号字符串字面量 + if strings.HasPrefix(content, "\"") && strings.HasSuffix(content, "\"") && len(content) > 1 { + return &nameSegment{name: content[1 : len(content)-1]}, nil + } + return &nameSegment{name: content}, nil } diff --git a/parser_test.go b/parser_test.go index 60dfbaa..e5ff6ef 100644 --- a/parser_test.go +++ b/parser_test.go @@ -99,7 +99,7 @@ func TestParseMultiIndexSegment(t *testing.T) { tests := []struct { name string content string - want *multiIndexSegment + want segment wantErr bool }{ { @@ -152,6 +152,36 @@ func TestParseMultiIndexSegment(t *testing.T) { content: ",1,2", wantErr: true, }, + { + name: "single quoted name", + content: "'name'", + want: &multiNameSegment{names: []string{"name"}}, + wantErr: false, + }, + { + name: "multiple quoted names", + content: "'name','age'", + want: &multiNameSegment{names: []string{"name", "age"}}, + wantErr: false, + }, + { + name: "mixed quoted names and indices", + content: "'name',1,'age'", + want: &multiNameSegment{names: []string{"name", "1", "age"}}, + wantErr: false, + }, + { + name: "unquoted names", + content: "name,age", + want: &multiNameSegment{names: []string{"name", "age"}}, + wantErr: false, + }, + { + name: "quoted names with spaces", + content: "'first name','last name'", + want: &multiNameSegment{names: []string{"first name", "last name"}}, + wantErr: false, + }, } for _, tt := range tests { @@ -620,6 +650,44 @@ func TestParseIndexOrName(t *testing.T) { wantValue: "你好", }, + // 双引号字符串字面量 + { + name: "double quoted string", + content: "\"hello\"", + wantType: "nameSegment", + wantValue: "hello", + }, + { + name: "empty double quoted string", + content: "\"\"", + wantType: "nameSegment", + wantValue: "", + }, + { + name: "double quoted string with spaces", + content: "\"hello world\"", + wantType: "nameSegment", + wantValue: "hello world", + }, + { + name: "double quoted string with special characters", + content: "\"Number of Moons\"", + wantType: "nameSegment", + wantValue: "Number of Moons", + }, + { + name: "double quoted string with numbers", + content: "\"123\"", + wantType: "nameSegment", + wantValue: "123", + }, + { + name: "double quoted string with unicode", + content: "\"你好\"", + wantType: "nameSegment", + wantValue: "你好", + }, + // 普通名称 { name: "unquoted string", diff --git a/segments.go b/segments.go index f066127..40dab3c 100644 --- a/segments.go +++ b/segments.go @@ -703,3 +703,34 @@ func (s *functionSegment) String() string { } return fmt.Sprintf("%s(%s)", s.name, strings.Join(args, ",")) } + +// 多字段段 +type multiNameSegment struct { + names []string +} + +func (s *multiNameSegment) evaluate(value interface{}) ([]interface{}, error) { + obj, ok := value.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("multi-name can only be applied to object") + } + + var result []interface{} + + for _, name := range s.names { + // 获取字段值 + if val, exists := obj[name]; exists { + result = append(result, val) + } + } + + return result, nil +} + +func (s *multiNameSegment) String() string { + names := make([]string, len(s.names)) + for i, name := range s.names { + names[i] = fmt.Sprintf("'%s'", name) + } + return fmt.Sprintf("[%s]", strings.Join(names, ",")) +} diff --git a/segments_test.go b/segments_test.go index 343a97c..21611c9 100644 --- a/segments_test.go +++ b/segments_test.go @@ -1187,6 +1187,117 @@ func TestMultiIndexSegmentEvaluate(t *testing.T) { } } +func TestMultiNameSegmentEvaluate(t *testing.T) { + tests := []struct { + name string + segment *multiNameSegment + value interface{} + want []interface{} + wantErr bool + }{ + { + name: "simple names", + segment: &multiNameSegment{ + names: []string{"name", "age"}, + }, + value: map[string]interface{}{ + "name": "John", + "age": 30, + "city": "New York", + }, + want: []interface{}{"John", 30}, + wantErr: false, + }, + { + name: "missing field", + segment: &multiNameSegment{ + names: []string{"name", "salary"}, + }, + value: map[string]interface{}{ + "name": "John", + "age": 30, + }, + want: []interface{}{"John"}, + wantErr: false, + }, + { + name: "empty names", + segment: &multiNameSegment{ + names: []string{}, + }, + value: map[string]interface{}{ + "name": "John", + "age": 30, + }, + want: nil, + wantErr: false, + }, + { + name: "duplicate names", + segment: &multiNameSegment{ + names: []string{"name", "name", "age"}, + }, + value: map[string]interface{}{ + "name": "John", + "age": 30, + }, + want: []interface{}{"John", "John", 30}, + wantErr: false, + }, + { + name: "non-object value", + segment: &multiNameSegment{ + names: []string{"name", "age"}, + }, + value: "not an object", + want: nil, + wantErr: true, + }, + { + name: "nil value", + segment: &multiNameSegment{ + names: []string{"name", "age"}, + }, + value: nil, + want: nil, + wantErr: true, + }, + { + name: "mixed value types", + segment: &multiNameSegment{ + names: []string{"name", "age", "active", "score"}, + }, + value: map[string]interface{}{ + "name": "John", + "age": 30, + "active": true, + "score": 95.5, + }, + want: []interface{}{"John", 30, true, 95.5}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.segment.evaluate(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("multiNameSegment.evaluate() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + if tt.want == nil { + if len(got) != 0 { + t.Errorf("multiNameSegment.evaluate() = %v, want nil or empty slice", got) + } + } else if !reflect.DeepEqual(got, tt.want) { + t.Errorf("multiNameSegment.evaluate() = %v, want %v", got, tt.want) + } + } + }) + } +} + func TestFunctionSegmentEvaluate(t *testing.T) { tests := []struct { name string diff --git a/test_data.json b/test_data.json new file mode 100644 index 0000000..1489ab6 --- /dev/null +++ b/test_data.json @@ -0,0 +1,33 @@ +{ + "star": { + "name": "Sun", + "diameter": 1391016, + "age": null, + "planets": [ + { + "name": "Mercury", + "Number of Moons": "0", + "diameter": 4879, + "has-moons": false + }, + { + "name": "Venus", + "Number of Moons": "0", + "diameter": 12104, + "has-moons": false + }, + { + "name": "Earth", + "Number of Moons": "1", + "diameter": 12756, + "has-moons": true + }, + { + "name": "Mars", + "Number of Moons": "2", + "diameter": 6792, + "has-moons": true + } + ] + } +} \ No newline at end of file