diff --git a/processor/xpath/xpath.go b/processor/xpath/xpath.go new file mode 100644 index 0000000..9fc03d2 --- /dev/null +++ b/processor/xpath/xpath.go @@ -0,0 +1,98 @@ +package xpath + +import "errors" + +// XPathStep represents a single step in an XPath expression +type XPathStep struct { + Type StepType + Name string + Predicate *Predicate +} + +// StepType defines the type of XPath step +type StepType int + +const ( + // RootStep represents the root step (/) + RootStep StepType = iota + // ChildStep represents a child element step (element) + ChildStep + // RecursiveDescentStep represents a recursive descent step (//) + RecursiveDescentStep + // WildcardStep represents a wildcard step (*) + WildcardStep + // PredicateStep represents a predicate condition step ([...]) + PredicateStep +) + +// PredicateType defines the type of XPath predicate +type PredicateType int + +const ( + // IndexPredicate represents an index predicate [n] + IndexPredicate PredicateType = iota + // LastPredicate represents a last() function predicate + LastPredicate + // LastMinusPredicate represents a last()-n predicate + LastMinusPredicate + // PositionPredicate represents position()-based predicates + PositionPredicate + // AttributeExistsPredicate represents [@attr] predicate + AttributeExistsPredicate + // AttributeEqualsPredicate represents [@attr='value'] predicate + AttributeEqualsPredicate + // ComparisonPredicate represents element comparison predicates + ComparisonPredicate +) + +// Predicate represents a condition in XPath +type Predicate struct { + Type PredicateType + Index int + Offset int + Attribute string + Value string + Expression string +} + +// XMLNode represents a node in the result set with its value and path +type XMLNode struct { + Value interface{} + Path string +} + +// ParseXPath parses an XPath expression into a series of steps +func ParseXPath(path string) ([]XPathStep, error) { + if path == "" { + return nil, errors.New("empty path") + } + + // This is just a placeholder implementation for the tests + // The actual implementation would parse the XPath expression + return nil, errors.New("not implemented") +} + +// Get retrieves nodes from XML data using an XPath expression +func Get(data interface{}, path string) ([]XMLNode, error) { + if data == "" { + return nil, errors.New("empty XML data") + } + + // This is just a placeholder implementation for the tests + // The actual implementation would evaluate the XPath against the XML + return nil, errors.New("not implemented") +} + +// Set updates a node in the XML data using an XPath expression +func Set(xmlData string, path string, value interface{}) (string, error) { + // This is just a placeholder implementation for the tests + // The actual implementation would modify the XML based on the XPath + return "", errors.New("not implemented") +} + +// SetAll updates all nodes matching an XPath expression in the XML data +func SetAll(xmlData string, path string, value interface{}) (string, error) { + // This is just a placeholder implementation for the tests + // The actual implementation would modify all matching nodes + return "", errors.New("not implemented") +} diff --git a/processor/xpath/xpath_test.go b/processor/xpath/xpath_test.go new file mode 100644 index 0000000..44c3e90 --- /dev/null +++ b/processor/xpath/xpath_test.go @@ -0,0 +1,545 @@ +package xpath + +import ( + "reflect" + "testing" +) + +// XML test data as a string for our tests +var testXML = ` + + + The Fellowship of the Ring + J.R.R. Tolkien + 1954 + 22.99 + + + The Two Towers + J.R.R. Tolkien + 1954 + 23.45 + + + Learning XML + Erik T. Ray + 2003 + 39.95 + + + red + 199.95 + + +` + +func TestParser(t *testing.T) { + tests := []struct { + path string + steps []XPathStep + wantErr bool + }{ + { + path: "/store/bicycle/color", + steps: []XPathStep{ + {Type: RootStep}, + {Type: ChildStep, Name: "store"}, + {Type: ChildStep, Name: "bicycle"}, + {Type: ChildStep, Name: "color"}, + }, + }, + { + path: "//price", + steps: []XPathStep{ + {Type: RootStep}, + {Type: RecursiveDescentStep, Name: "price"}, + }, + }, + { + path: "/store/book/*", + steps: []XPathStep{ + {Type: RootStep}, + {Type: ChildStep, Name: "store"}, + {Type: ChildStep, Name: "book"}, + {Type: WildcardStep}, + }, + }, + { + path: "/store/book[1]/title", + steps: []XPathStep{ + {Type: RootStep}, + {Type: ChildStep, Name: "store"}, + {Type: ChildStep, Name: "book"}, + {Type: PredicateStep, Predicate: &Predicate{Type: IndexPredicate, Index: 1}}, + {Type: ChildStep, Name: "title"}, + }, + }, + { + path: "//title[@lang]", + steps: []XPathStep{ + {Type: RootStep}, + {Type: RecursiveDescentStep, Name: "title"}, + {Type: PredicateStep, Predicate: &Predicate{Type: AttributeExistsPredicate, Attribute: "lang"}}, + }, + }, + { + path: "//title[@lang='en']", + steps: []XPathStep{ + {Type: RootStep}, + {Type: RecursiveDescentStep, Name: "title"}, + {Type: PredicateStep, Predicate: &Predicate{ + Type: AttributeEqualsPredicate, + Attribute: "lang", + Value: "en", + }}, + }, + }, + { + path: "/store/book[price>35.00]/title", + steps: []XPathStep{ + {Type: RootStep}, + {Type: ChildStep, Name: "store"}, + {Type: ChildStep, Name: "book"}, + {Type: PredicateStep, Predicate: &Predicate{ + Type: ComparisonPredicate, + Expression: "price>35.00", + }}, + {Type: ChildStep, Name: "title"}, + }, + }, + { + path: "/store/book[last()]", + steps: []XPathStep{ + {Type: RootStep}, + {Type: ChildStep, Name: "store"}, + {Type: ChildStep, Name: "book"}, + {Type: PredicateStep, Predicate: &Predicate{Type: LastPredicate}}, + }, + }, + { + path: "/store/book[last()-1]", + steps: []XPathStep{ + {Type: RootStep}, + {Type: ChildStep, Name: "store"}, + {Type: ChildStep, Name: "book"}, + {Type: PredicateStep, Predicate: &Predicate{ + Type: LastMinusPredicate, + Offset: 1, + }}, + }, + }, + { + path: "/store/book[position()<3]", + steps: []XPathStep{ + {Type: RootStep}, + {Type: ChildStep, Name: "store"}, + {Type: ChildStep, Name: "book"}, + {Type: PredicateStep, Predicate: &Predicate{ + Type: PositionPredicate, + Expression: "position()<3", + }}, + }, + }, + { + path: "invalid/path", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + steps, err := ParseXPath(tt.path) + if (err != nil) != tt.wantErr { + t.Fatalf("ParseXPath() error = %v, wantErr %v", err, tt.wantErr) + } + if !tt.wantErr && !reflect.DeepEqual(steps, tt.steps) { + t.Errorf("ParseXPath() steps = %+v, want %+v", steps, tt.steps) + } + }) + } +} + +func TestEvaluator(t *testing.T) { + tests := []struct { + name string + path string + expected []XMLNode + error bool + }{ + { + name: "simple_element_access", + path: "/store/bicycle/color", + expected: []XMLNode{ + {Value: "red", Path: "/store/bicycle/color"}, + }, + }, + { + name: "recursive_element_access", + path: "//price", + expected: []XMLNode{ + {Value: "22.99", Path: "/store/book[1]/price"}, + {Value: "23.45", Path: "/store/book[2]/price"}, + {Value: "39.95", Path: "/store/book[3]/price"}, + {Value: "199.95", Path: "/store/bicycle/price"}, + }, + }, + { + name: "wildcard_element_access", + path: "/store/book[1]/*", + expected: []XMLNode{ + {Value: "The Fellowship of the Ring", Path: "/store/book[1]/title"}, + {Value: "J.R.R. Tolkien", Path: "/store/book[1]/author"}, + {Value: "1954", Path: "/store/book[1]/year"}, + {Value: "22.99", Path: "/store/book[1]/price"}, + }, + }, + { + name: "indexed_element_access", + path: "/store/book[1]/title", + expected: []XMLNode{ + {Value: "The Fellowship of the Ring", Path: "/store/book[1]/title"}, + }, + }, + { + name: "attribute_exists_predicate", + path: "//title[@lang]", + expected: []XMLNode{ + {Value: "The Fellowship of the Ring", Path: "/store/book[1]/title"}, + {Value: "The Two Towers", Path: "/store/book[2]/title"}, + {Value: "Learning XML", Path: "/store/book[3]/title"}, + }, + }, + { + name: "attribute_equals_predicate", + path: "//title[@lang='en']", + expected: []XMLNode{ + {Value: "The Fellowship of the Ring", Path: "/store/book[1]/title"}, + {Value: "The Two Towers", Path: "/store/book[2]/title"}, + {Value: "Learning XML", Path: "/store/book[3]/title"}, + }, + }, + { + name: "value_comparison_predicate", + path: "/store/book[price>35.00]/title", + expected: []XMLNode{ + {Value: "Learning XML", Path: "/store/book[3]/title"}, + }, + }, + { + name: "last_predicate", + path: "/store/book[last()]/title", + expected: []XMLNode{ + {Value: "Learning XML", Path: "/store/book[3]/title"}, + }, + }, + { + name: "last_minus_predicate", + path: "/store/book[last()-1]/title", + expected: []XMLNode{ + {Value: "The Two Towers", Path: "/store/book[2]/title"}, + }, + }, + { + name: "position_predicate", + path: "/store/book[position()<3]/title", + expected: []XMLNode{ + {Value: "The Fellowship of the Ring", Path: "/store/book[1]/title"}, + {Value: "The Two Towers", Path: "/store/book[2]/title"}, + }, + }, + { + name: "all_elements", + path: "//*", + expected: []XMLNode{ + // For brevity, we'll just check the count, not all values + }, + }, + { + name: "invalid_index", + path: "/store/book[10]/title", + expected: []XMLNode{}, + error: true, + }, + { + name: "nonexistent_element", + path: "/store/nonexistent", + expected: []XMLNode{}, + error: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := Get(testXML, tt.path) + if err != nil { + if !tt.error { + t.Errorf("Get() returned error: %v", err) + } + return + } + + // Special handling for the "//*" test case + if tt.path == "//*" { + // Just check that we got multiple elements, not the specific count + if len(result) < 10 { // We expect at least 10 elements + t.Errorf("Expected multiple elements for '//*', got %d", len(result)) + } + return + } + + if len(result) != len(tt.expected) { + t.Errorf("Expected %d items, got %d", len(tt.expected), len(result)) + return + } + + // Validate both values and paths + for i, e := range tt.expected { + if i < len(result) { + if !reflect.DeepEqual(result[i].Value, e.Value) { + t.Errorf("Value at [%d]: got %v, expected %v", i, result[i].Value, e.Value) + } + if result[i].Path != e.Path { + t.Errorf("Path at [%d]: got %s, expected %s", i, result[i].Path, e.Path) + } + } + } + }) + } +} + +func TestEdgeCases(t *testing.T) { + t.Run("empty_data", func(t *testing.T) { + result, err := Get("", "/store/book") + if err == nil { + t.Errorf("Expected error for empty data") + return + } + if len(result) > 0 { + t.Errorf("Expected empty result, got %v", result) + } + }) + + t.Run("empty_path", func(t *testing.T) { + _, err := ParseXPath("") + if err == nil { + t.Error("Expected error for empty path") + } + }) + + t.Run("invalid_xml", func(t *testing.T) { + _, err := Get("xml", "/store") + if err == nil { + t.Error("Expected error for invalid XML") + } + }) + + t.Run("current_node", func(t *testing.T) { + result, err := Get(testXML, "/store/book[1]/.") + if err != nil { + t.Errorf("Get() returned error: %v", err) + return + } + if len(result) != 1 { + t.Errorf("Expected 1 result, got %d", len(result)) + } + }) + + t.Run("attributes", func(t *testing.T) { + result, err := Get(testXML, "/store/book[1]/title/@lang") + if err != nil { + t.Errorf("Get() returned error: %v", err) + return + } + if len(result) != 1 || result[0].Value != "en" { + t.Errorf("Expected 'en', got %v", result) + } + }) +} + +func TestGetWithPaths(t *testing.T) { + tests := []struct { + name string + path string + expected []XMLNode + }{ + { + name: "simple_element_access", + path: "/store/bicycle/color", + expected: []XMLNode{ + {Value: "red", Path: "/store/bicycle/color"}, + }, + }, + { + name: "indexed_element_access", + path: "/store/book[1]/title", + expected: []XMLNode{ + {Value: "The Fellowship of the Ring", Path: "/store/book[1]/title"}, + }, + }, + { + name: "recursive_element_access", + path: "//price", + expected: []XMLNode{ + {Value: "22.99", Path: "/store/book[1]/price"}, + {Value: "23.45", Path: "/store/book[2]/price"}, + {Value: "39.95", Path: "/store/book[3]/price"}, + {Value: "199.95", Path: "/store/bicycle/price"}, + }, + }, + { + name: "attribute_access", + path: "/store/book[1]/title/@lang", + expected: []XMLNode{ + {Value: "en", Path: "/store/book[1]/title/@lang"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := Get(testXML, tt.path) + if err != nil { + t.Errorf("Get() returned error: %v", err) + return + } + + // Check if lengths match + if len(result) != len(tt.expected) { + t.Errorf("Get() returned %d items, expected %d", len(result), len(tt.expected)) + return + } + + // For each expected item, find its match in the results and verify both value and path + for _, expected := range tt.expected { + found := false + for _, r := range result { + // First verify the value matches + if reflect.DeepEqual(r.Value, expected.Value) { + found = true + // Then verify the path matches + if r.Path != expected.Path { + t.Errorf("Path mismatch for value %v: got %s, expected %s", r.Value, r.Path, expected.Path) + } + break + } + } + if !found { + t.Errorf("Expected node with value %v and path %s not found in results", expected.Value, expected.Path) + } + } + }) + } +} + +func TestSet(t *testing.T) { + t.Run("simple element", func(t *testing.T) { + xmlData := `John` + newXML, err := Set(xmlData, "/root/name", "Jane") + if err != nil { + t.Errorf("Set() returned error: %v", err) + return + } + + // Verify the change + result, err := Get(newXML, "/root/name") + if err != nil { + t.Errorf("Get() returned error: %v", err) + return + } + if len(result) != 1 || result[0].Value != "Jane" { + t.Errorf("Set() failed: expected name to be 'Jane', got %v", result) + } + }) + + t.Run("attribute", func(t *testing.T) { + xmlData := `` + newXML, err := Set(xmlData, "/root/element/@id", "456") + if err != nil { + t.Errorf("Set() returned error: %v", err) + return + } + + // Verify the change + result, err := Get(newXML, "/root/element/@id") + if err != nil { + t.Errorf("Get() returned error: %v", err) + return + } + if len(result) != 1 || result[0].Value != "456" { + t.Errorf("Set() failed: expected id to be '456', got %v", result) + } + }) + + t.Run("indexed element", func(t *testing.T) { + xmlData := `firstsecond` + newXML, err := Set(xmlData, "/root/items/item[1]", "changed") + if err != nil { + t.Errorf("Set() returned error: %v", err) + return + } + + // Verify the change + result, err := Get(newXML, "/root/items/item[1]") + if err != nil { + t.Errorf("Get() returned error: %v", err) + return + } + if len(result) != 1 || result[0].Value != "changed" { + t.Errorf("Set() failed: expected item to be 'changed', got %v", result) + } + }) +} + +func TestSetAll(t *testing.T) { + t.Run("multiple elements", func(t *testing.T) { + xmlData := `firstsecond` + newXML, err := SetAll(xmlData, "//item", "changed") + if err != nil { + t.Errorf("SetAll() returned error: %v", err) + return + } + + // Verify all items are changed + result, err := Get(newXML, "//item") + if err != nil { + t.Errorf("Get() returned error: %v", err) + return + } + if len(result) != 2 { + t.Errorf("Expected 2 results, got %d", len(result)) + return + } + + for i, node := range result { + if node.Value != "changed" { + t.Errorf("Item %d not changed, got %v", i+1, node.Value) + } + } + }) + + t.Run("attributes", func(t *testing.T) { + xmlData := `` + newXML, err := SetAll(xmlData, "//item/@id", "new") + if err != nil { + t.Errorf("SetAll() returned error: %v", err) + return + } + + // Verify all attributes are changed + result, err := Get(newXML, "//item/@id") + if err != nil { + t.Errorf("Get() returned error: %v", err) + return + } + if len(result) != 2 { + t.Errorf("Expected 2 results, got %d", len(result)) + return + } + + for i, node := range result { + if node.Value != "new" { + t.Errorf("Attribute %d not changed, got %v", i+1, node.Value) + } + } + }) +}