6 Commits

Author SHA1 Message Date
4eed05c7c2 Fix some more minor bugs and tests 2025-03-25 19:14:21 +01:00
4640281fbf Enable root modifications
Though I can not see why you would want to.....
But there's no reason you would not be able to
2025-03-25 18:57:32 +01:00
aba10267d1 Fix more tests 2025-03-25 18:47:55 +01:00
fed140254b Fix some json tests 2025-03-25 18:32:51 +01:00
db92033642 Rework rounding and building lua script
To allow user script to specify what was modified where
2025-03-25 18:28:33 +01:00
1b0b198297 Add xpath tests 2025-03-25 17:56:48 +01:00
7 changed files with 954 additions and 149 deletions

View File

@@ -7,7 +7,6 @@ import (
"modify/processor/jsonpath"
"os"
"path/filepath"
"strings"
lua "github.com/yuin/gopher-lua"
)
@@ -68,6 +67,7 @@ func (p *JSONProcessor) ProcessContent(content string, pattern string, luaExpr s
return content, 0, 0, nil
}
modCount := 0
for _, node := range nodes {
log.Printf("Processing node at path: %s with value: %v", node.Path, node.Value)
@@ -85,10 +85,14 @@ func (p *JSONProcessor) ProcessContent(content string, pattern string, luaExpr s
}
log.Printf("Converted node value to Lua: %v", node.Value)
originalScript := luaExpr
fullScript := BuildLuaScript(luaExpr)
log.Printf("Original script: %q, Full script: %q", originalScript, fullScript)
// Execute Lua script
log.Printf("Executing Lua script: %s", luaExpr)
if err := L.DoString(luaExpr); err != nil {
return content, len(nodes), 0, fmt.Errorf("error executing Lua %s: %v", luaExpr, err)
log.Printf("Executing Lua script: %q", fullScript)
if err := L.DoString(fullScript); err != nil {
return content, len(nodes), 0, fmt.Errorf("error executing Lua %q: %v", fullScript, err)
}
log.Println("Lua script executed successfully.")
@@ -99,51 +103,29 @@ func (p *JSONProcessor) ProcessContent(content string, pattern string, luaExpr s
}
log.Printf("Retrieved modified value from Lua: %v", result)
modified := false
modified = L.GetGlobal("modified").String() == "true"
if !modified {
log.Printf("No changes made to node at path: %s", node.Path)
continue
}
// Apply the modification to the JSON data
err = p.updateJSONValue(jsonData, node.Path, result)
if err != nil {
return content, len(nodes), 0, fmt.Errorf("error updating JSON: %v", err)
}
log.Printf("Updated JSON at path: %s with new value: %v", node.Path, result)
modCount++
}
// Convert the modified JSON back to a string with same formatting
var jsonBytes []byte
if indent, err := detectJsonIndentation(content); err == nil && indent != "" {
// Use detected indentation for output formatting
jsonBytes, err = json.MarshalIndent(jsonData, "", indent)
} else {
// Fall back to standard 2-space indent
jsonBytes, err = json.MarshalIndent(jsonData, "", " ")
if err != nil {
return content, modCount, matchCount, fmt.Errorf("error marshalling JSON: %v", err)
}
// We changed all the nodes trust me bro
return string(jsonBytes), len(nodes), len(nodes), nil
}
// detectJsonIndentation tries to determine the indentation used in the original JSON
func detectJsonIndentation(content string) (string, error) {
lines := strings.Split(content, "\n")
if len(lines) < 2 {
return "", fmt.Errorf("not enough lines to detect indentation")
}
// Look for the first indented line
for i := 1; i < len(lines); i++ {
line := lines[i]
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
// Calculate leading whitespace
indent := line[:len(line)-len(trimmed)]
if len(indent) > 0 {
return indent, nil
}
}
return "", fmt.Errorf("no indentation detected")
return string(jsonBytes), modCount, matchCount, nil
}
// / Selects from the root node
@@ -165,6 +147,52 @@ func detectJsonIndentation(content string) (string, error) {
// updateJSONValue updates a value in the JSON structure based on its JSONPath
func (p *JSONProcessor) updateJSONValue(jsonData interface{}, path string, newValue interface{}) error {
// Special handling for root node
if path == "$" {
// For the root node, we'll copy the value to the jsonData reference
// This is a special case since we can't directly replace the interface{} variable
// We need to handle different types of root elements
switch rootValue := newValue.(type) {
case map[string]interface{}:
// For objects, we need to copy over all keys
rootMap, ok := jsonData.(map[string]interface{})
if !ok {
// If the original wasn't a map, completely replace it with the new map
// This is handled by the jsonpath.Set function
return jsonpath.Set(jsonData, path, newValue)
}
// Clear the original map
for k := range rootMap {
delete(rootMap, k)
}
// Copy all keys from the new map
for k, v := range rootValue {
rootMap[k] = v
}
return nil
case []interface{}:
// For arrays, we need to handle similarly
rootArray, ok := jsonData.([]interface{})
if !ok {
// If the original wasn't an array, use jsonpath.Set
return jsonpath.Set(jsonData, path, newValue)
}
// Clear and recreate the array
*&rootArray = rootValue
return nil
default:
// For other types, use jsonpath.Set
return jsonpath.Set(jsonData, path, newValue)
}
}
// For non-root paths, use the regular Set method
err := jsonpath.Set(jsonData, path, newValue)
if err != nil {
return fmt.Errorf("failed to update JSON value at path '%s': %w", path, err)

View File

@@ -133,24 +133,24 @@ func TestJSONProcessor_Process_StringValues(t *testing.T) {
t.Logf("Result: %s", result)
t.Logf("Match count: %d, Mod count: %d", matchCount, modCount)
if matchCount != 3 {
t.Errorf("Expected 3 matches, got %d", matchCount)
if matchCount != 1 {
t.Errorf("Expected 1 matches, got %d", matchCount)
}
if modCount != 3 {
t.Errorf("Expected 3 modifications, got %d", modCount)
if modCount != 1 {
t.Errorf("Expected 1 modifications, got %d", modCount)
}
// Check that all expected values are in the result
if !strings.Contains(result, `"maxItems": "200"`) {
if !strings.Contains(result, `"maxItems": 200`) {
t.Errorf("Result missing expected value: maxItems=200")
}
if !strings.Contains(result, `"itemTimeoutSecs": "60"`) {
if !strings.Contains(result, `"itemTimeoutSecs": 60`) {
t.Errorf("Result missing expected value: itemTimeoutSecs=60")
}
if !strings.Contains(result, `"retryCount": "10"`) {
if !strings.Contains(result, `"retryCount": 10`) {
t.Errorf("Result missing expected value: retryCount=10")
}
}
@@ -265,13 +265,13 @@ func TestJSONProcessor_NestedModifications(t *testing.T) {
"book": [
{
"category": "reference",
"title": "Learn Go in 24 Hours",
"price": 13.188
"price": 13.188,
"title": "Learn Go in 24 Hours"
},
{
"category": "fiction",
"title": "The Go Developer",
"price": 10.788
"price": 10.788,
"title": "The Go Developer"
}
]
}
@@ -373,20 +373,20 @@ func TestJSONProcessor_ComplexScript(t *testing.T) {
expected := `{
"products": [
{
"discount": 0.1,
"name": "Basic Widget",
"price": 8.991,
"discount": 0.1
"price": 8.991
},
{
"discount": 0.05,
"name": "Premium Widget",
"price": 18.9905,
"discount": 0.05
"price": 18.9905
}
]
}`
p := &JSONProcessor{}
result, modCount, matchCount, err := p.ProcessContent(content, "$.products[*]", "v.price = v.price * (1 - v.discount)")
result, modCount, matchCount, err := p.ProcessContent(content, "$.products[*]", "v.price = round(v.price * (1 - v.discount), 4)")
if err != nil {
t.Fatalf("Error processing content: %v", err)
@@ -419,11 +419,24 @@ func TestJSONProcessor_SpecificItemUpdate(t *testing.T) {
]
}`
expected := `{
expected := `
{
"items": [
{"id": 1, "name": "Item 1", "stock": 10},
{"id": 2, "name": "Item 2", "stock": 15},
{"id": 3, "name": "Item 3", "stock": 0}
{
"id": 1,
"name": "Item 1",
"stock": 10
},
{
"id": 2,
"name": "Item 2",
"stock": 15
},
{
"id": 3,
"name": "Item 3",
"stock": 0
}
]
} `
@@ -454,7 +467,9 @@ func TestJSONProcessor_SpecificItemUpdate(t *testing.T) {
// TestJSONProcessor_RootElementUpdate tests updating the root element
func TestJSONProcessor_RootElementUpdate(t *testing.T) {
content := `{"value": 100}`
expected := `{"value": 200}`
expected := `{
"value": 200
}`
p := &JSONProcessor{}
result, modCount, matchCount, err := p.ProcessContent(content, "$.value", "v=v*2")
@@ -471,7 +486,11 @@ func TestJSONProcessor_RootElementUpdate(t *testing.T) {
t.Errorf("Expected 1 modification, got %d", modCount)
}
if result != expected {
// Normalize whitespace for comparison
normalizedResult := normalizeWhitespace(result)
normalizedExpected := normalizeWhitespace(expected)
if normalizedResult != normalizedExpected {
t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result)
}
}
@@ -487,9 +506,9 @@ func TestJSONProcessor_AddNewField(t *testing.T) {
expected := `{
"user": {
"name": "John",
"age": 30,
"email": "john@example.com"
"email": "john@example.com",
"name": "John"
}
}`
@@ -529,8 +548,8 @@ func TestJSONProcessor_RemoveField(t *testing.T) {
expected := `{
"user": {
"name": "John",
"email": "john@example.com"
"email": "john@example.com",
"name": "John"
}
}`
@@ -565,7 +584,12 @@ func TestJSONProcessor_ArrayManipulation(t *testing.T) {
}`
expected := ` {
"tags": ["GO", "JSON", "LUA", "testing"]
"tags": [
"GO",
"JSON",
"LUA",
"testing"
]
}`
p := &JSONProcessor{}
@@ -624,28 +648,29 @@ func TestJSONProcessor_ConditionalModification(t *testing.T) {
expected := `{
"products": [
{
"inStock": true,
"name": "Product A",
"price": 9.891,
"inStock": true
"price": 9.891
},
{
"inStock": false,
"name": "Product B",
"price": 5.99,
"inStock": false
"price": 5.99
},
{
"inStock": true,
"name": "Product C",
"price": 14.391,
"inStock": true
"price": 14.391
}
]
}`
p := &JSONProcessor{}
result, modCount, matchCount, err := p.ProcessContent(content, "$.products[*]", `
if v.inStock then
v.price = v.price * 0.9
if not v.inStock then
return false
end
v.price = v.price * 0.9
`)
if err != nil {
@@ -695,15 +720,15 @@ func TestJSONProcessor_DeepNesting(t *testing.T) {
"departments": {
"engineering": {
"teams": {
"frontend": {
"members": 12,
"projects": 5,
"status": "active"
},
"backend": {
"members": 8,
"projects": 3,
"status": "active"
},
"frontend": {
"members": 12,
"projects": 5,
"status": "active"
}
}
}
@@ -763,31 +788,31 @@ func TestJSONProcessor_ComplexTransformation(t *testing.T) {
expected := `{
"order": {
"items": [
{
"product": "Widget A",
"quantity": 5,
"price": 10.0,
"total": 50.0,
"discounted_total": 45.0
},
{
"product": "Widget B",
"quantity": 3,
"price": 15.0,
"total": 45.0,
"discounted_total": 40.5
}
],
"customer": {
"name": "John Smith",
"tier": "gold"
},
"items": [
{
"discounted_total": 45,
"price": 10,
"product": "Widget A",
"quantity": 5,
"total": 50
},
{
"discounted_total": 40.5,
"price": 15,
"product": "Widget B",
"quantity": 3,
"total": 45
}
],
"summary": {
"total_items": 8,
"subtotal": 95.0,
"discount": 9.5,
"total": 85.5
"subtotal": 95,
"total": 85.5,
"total_items": 8
}
}
}`
@@ -912,16 +937,16 @@ func TestJSONProcessor_RestructuringData(t *testing.T) {
"people": {
"developers": [
{
"age": 25,
"id": 1,
"name": "Alice",
"age": 25
"name": "Alice"
}
],
"managers": [
{
"age": 30,
"id": 2,
"name": "Bob",
"age": 30
"name": "Bob"
}
]
}
@@ -982,7 +1007,13 @@ func TestJSONProcessor_FilteringArrayElements(t *testing.T) {
}`
expected := `{
"numbers": [2, 4, 6, 8, 10]
"numbers": [
2,
4,
6,
8,
10
]
}`
p := &JSONProcessor{}
@@ -1017,3 +1048,51 @@ func TestJSONProcessor_FilteringArrayElements(t *testing.T) {
t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result)
}
}
// TestJSONProcessor_RootNodeModification tests modifying the root node directly
func TestJSONProcessor_RootNodeModification(t *testing.T) {
content := `{
"name": "original",
"value": 100
}`
expected := `{
"description": "This is a completely modified root",
"name": "modified",
"values": [
1,
2,
3
]
}`
p := &JSONProcessor{}
result, modCount, matchCount, err := p.ProcessContent(content, "$", `
-- Completely replace the root node
v = {
name = "modified",
description = "This is a completely modified root",
values = {1, 2, 3}
}
`)
if err != nil {
t.Fatalf("Error processing content: %v", err)
}
if matchCount != 1 {
t.Errorf("Expected 1 match, got %d", matchCount)
}
if modCount != 1 {
t.Errorf("Expected 1 modification, got %d", modCount)
}
// Normalize whitespace for comparison
normalizedResult := normalizeWhitespace(result)
normalizedExpected := normalizeWhitespace(expected)
if normalizedResult != normalizedExpected {
t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result)
}
}

View File

@@ -138,10 +138,6 @@ func Set(data interface{}, path string, value interface{}) error {
return fmt.Errorf("failed to parse JSONPath %q: %w", path, err)
}
if len(steps) <= 1 {
return fmt.Errorf("cannot set root node; the provided path %q is invalid", path)
}
success := false
err = setWithPath(data, steps, &success, value, "$", ModifyFirstMode)
if err != nil {
@@ -157,10 +153,6 @@ func SetAll(data interface{}, path string, value interface{}) error {
return fmt.Errorf("failed to parse JSONPath %q: %w", path, err)
}
if len(steps) <= 1 {
return fmt.Errorf("cannot set root node; the provided path %q is invalid", path)
}
success := false
err = setWithPath(data, steps, &success, value, "$", ModifyAllMode)
if err != nil {
@@ -178,17 +170,20 @@ func setWithPath(node interface{}, steps []JSONStep, success *bool, value interf
// Skip root step
actualSteps := steps
if len(steps) > 0 && steps[0].Type == RootStep {
if len(steps) == 1 {
return fmt.Errorf("cannot set root node; the provided path %q is invalid", currentPath)
}
actualSteps = steps[1:]
}
// Process the first step
// If we have no steps left, we're setting the root value
if len(actualSteps) == 0 {
return fmt.Errorf("cannot set root node; no steps provided for path %q", currentPath)
// For the root node, we need to handle it differently depending on what's passed in
// since we can't directly replace the interface{} variable
// We'll signal success and let the JSONProcessor handle updating the root
*success = true
return nil
}
// Process the first step
step := actualSteps[0]
remainingSteps := actualSteps[1:]
isLastStep := len(remainingSteps) == 0

View File

@@ -2,7 +2,6 @@ package processor
import (
"fmt"
"strconv"
"strings"
lua "github.com/yuin/gopher-lua"
@@ -81,6 +80,8 @@ func ToLua(L *lua.LState, data interface{}) (lua.LValue, error) {
return lua.LBool(v), nil
case float64:
return lua.LNumber(v), nil
case nil:
return lua.LNil, nil
default:
return nil, fmt.Errorf("unsupported data type: %T", data)
}
@@ -89,29 +90,31 @@ func ToLua(L *lua.LState, data interface{}) (lua.LValue, error) {
// FromLua converts a Lua table to a struct or map recursively
func FromLua(L *lua.LState, luaValue lua.LValue) (interface{}, error) {
switch v := luaValue.(type) {
// Well shit...
// Tables in lua are both maps and arrays
// As arrays they are ordered and as maps, obviously, not
// So when we parse them to a go map we fuck up the order for arrays
// We have to find a better way....
case *lua.LTable:
result := make(map[string]interface{})
v.ForEach(func(key lua.LValue, value lua.LValue) {
result[key.String()], _ = FromLua(L, value)
})
// This may be a bit wasteful...
// Hopefully it won't run often enough to matter
isArray := true
for key := range result {
_, err := strconv.Atoi(key)
isArray, err := IsLuaTableArray(L, v)
if err != nil {
isArray = false
break
}
return nil, err
}
if isArray {
list := make([]interface{}, 0, len(result))
for _, value := range result {
list = append(list, value)
}
return list, nil
}
result := make([]interface{}, 0)
v.ForEach(func(key lua.LValue, value lua.LValue) {
converted, _ := FromLua(L, value)
result = append(result, converted)
})
return result, nil
} else {
result := make(map[string]interface{})
v.ForEach(func(key lua.LValue, value lua.LValue) {
converted, _ := FromLua(L, value)
result[key.String()] = converted
})
return result, nil
}
case lua.LString:
return string(v), nil
case lua.LBool:
@@ -123,13 +126,34 @@ func FromLua(L *lua.LState, luaValue lua.LValue) (interface{}, error) {
}
}
func IsLuaTableArray(L *lua.LState, v *lua.LTable) (bool, error) {
L.SetGlobal("table_to_check", v)
// Use our predefined helper function from InitLuaHelpers
err := L.DoString(`is_array = isArray(table_to_check)`)
if err != nil {
return false, fmt.Errorf("error determining if table is array: %w", err)
}
// Check the result of our Lua function
isArray := L.GetGlobal("is_array")
// LVIsFalse returns true if a given LValue is a nil or false otherwise false.
if !lua.LVIsFalse(isArray) {
return true, nil
}
return false, nil
}
// InitLuaHelpers initializes common Lua helper functions
func InitLuaHelpers(L *lua.LState) error {
helperScript := `
-- Custom Lua helpers for math operations
function min(a, b) return math.min(a, b) end
function max(a, b) return math.max(a, b) end
function round(x) return math.floor(x + 0.5) end
function round(x, n)
if n == nil then n = 0 end
return math.floor(x * 10^n + 0.5) / 10^n
end
function floor(x) return math.floor(x) end
function ceil(x) return math.ceil(x) end
function upper(s) return string.upper(s) end
@@ -149,6 +173,22 @@ end
function is_number(str)
return tonumber(str) ~= nil
end
function isArray(t)
if type(t) ~= "table" then return false end
local max = 0
local count = 0
for k, _ in pairs(t) do
if type(k) ~= "number" or k < 1 or math.floor(k) ~= k then
return false
end
max = math.max(max, k)
count = count + 1
end
return max == count
end
modified = false
`
if err := L.DoString(helperScript); err != nil {
return fmt.Errorf("error loading helper functions: %v", err)
@@ -167,8 +207,7 @@ func LimitString(s string, maxLen int) string {
return s[:maxLen-3] + "..."
}
// BuildLuaScript prepares a Lua expression from shorthand notation
func BuildLuaScript(luaExpr string) string {
func PrependLuaAssignment(luaExpr string) string {
// Auto-prepend v1 for expressions starting with operators
if strings.HasPrefix(luaExpr, "*") ||
strings.HasPrefix(luaExpr, "/") ||
@@ -186,10 +225,30 @@ func BuildLuaScript(luaExpr string) string {
if !strings.Contains(luaExpr, "=") {
luaExpr = "v1 = " + luaExpr
}
return luaExpr
}
// BuildLuaScript prepares a Lua expression from shorthand notation
func BuildLuaScript(luaExpr string) string {
luaExpr = PrependLuaAssignment(luaExpr)
// This allows the user to specify whether or not they modified a value
// If they do nothing we assume they did modify (no return at all)
// If they return before our return then they themselves specify what they did
// If nothing is returned lua assumes nil
// So we can say our value was modified if the return value is either nil or true
// If the return value is false then the user wants to keep the original
fullScript := fmt.Sprintf(`
function run()
%s
end
local res = run()
modified = res == nil or res
`, luaExpr)
return fullScript
}
// Max returns the maximum of two integers
func Max(a, b int) int {
if a > b {

View File

@@ -35,10 +35,11 @@ func TestBuildLuaScript(t *testing.T) {
{"v1 * 2", "v1 = v1 * 2"},
{"v1 * v2", "v1 = v1 * v2"},
{"v1 / v2", "v1 = v1 / v2"},
{"12", "v1 = 12"},
}
for _, c := range cases {
result := BuildLuaScript(c.input)
result := PrependLuaAssignment(c.input)
if result != c.expected {
t.Errorf("BuildLuaScript(%q): expected %q, got %q", c.input, c.expected, result)
}

98
processor/xpath/xpath.go Normal file
View File

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

View File

@@ -0,0 +1,545 @@
package xpath
import (
"reflect"
"testing"
)
// XML test data as a string for our tests
var testXML = `
<store>
<book category="fiction">
<title lang="en">The Fellowship of the Ring</title>
<author>J.R.R. Tolkien</author>
<year>1954</year>
<price>22.99</price>
</book>
<book category="fiction">
<title lang="en">The Two Towers</title>
<author>J.R.R. Tolkien</author>
<year>1954</year>
<price>23.45</price>
</book>
<book category="technical">
<title lang="en">Learning XML</title>
<author>Erik T. Ray</author>
<year>2003</year>
<price>39.95</price>
</book>
<bicycle>
<color>red</color>
<price>199.95</price>
</bicycle>
</store>
`
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("<invalid>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 := `<root><name>John</name></root>`
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 := `<root><element id="123"></element></root>`
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 := `<root><items><item>first</item><item>second</item></items></root>`
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 := `<root><items><item>first</item><item>second</item></items></root>`
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 := `<root><item id="1"/><item id="2"/></root>`
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)
}
}
})
}