Skip to content

Commit

Permalink
Support advanced path expressions
Browse files Browse the repository at this point in the history
  • Loading branch information
phanimarupaka committed Nov 18, 2020
1 parent 8a315c0 commit d0762af
Show file tree
Hide file tree
Showing 4 changed files with 165 additions and 22 deletions.
75 changes: 62 additions & 13 deletions internal/cmdsearch/searchreplace_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -293,8 +293,8 @@ metadata:
`,
},
{
name: "search by array objects path",
args: []string{"--by-path", "spec.foo[1].c"},
name: "search replace by array path regex",
args: []string{"--by-path", "spec.foo[1]", "--put-literal", "c"},
input: `
apiVersion: apps/v1
kind: Deployment
Expand All @@ -303,22 +303,60 @@ metadata:
spec:
replicas: 3
foo:
- c: thing0
- c: thing1
- c: thing2
---
- a
- b
`,
out: `${baseDir}/
matched 1 field(s)
${filePath}: spec.foo[1]: c
`,
expectedResources: `
apiVersion: apps/v1
kind: Service
kind: Deployment
metadata:
name: nginx-service
name: nginx-deployment
spec:
replicas: 3
foo:
- a
- c
`,
},
{
name: "search replace by array path out of bounds",
args: []string{"--by-path", "spec.foo[2]", "--put-literal", "c"},
input: `
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deployment
spec:
replicas: 3
foo:
- a
- b
`,
out: `${baseDir}/
matched 1 field(s)
${filePath}: spec.foo[1].c: thing1
matched 0 field(s)
`,
expectedResources: `
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deployment
spec:
replicas: 3
foo:
- a
- b
`,
},
{
name: "search replace by array objects path",
args: []string{"--by-path", "spec.foo[1].c", "--put-literal", "thing-new"},
input: `
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deployment
spec:
Expand All @@ -327,11 +365,22 @@ spec:
- c: thing0
- c: thing1
- c: thing2
---
`,
out: `${baseDir}/
matched 1 field(s)
${filePath}: spec.foo[1].c: thing-new
`,
expectedResources: `
apiVersion: apps/v1
kind: Service
kind: Deployment
metadata:
name: nginx-service
name: nginx-deployment
spec:
replicas: 3
foo:
- c: thing0
- c: thing-new
- c: thing2
`,
},
{
Expand Down
81 changes: 74 additions & 7 deletions internal/util/search/pathparser.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,84 @@ func (sr *SearchReplace) pathMatch(yamlPath string) bool {
if sr.ByPath == "" {
return false
}
inputElems := strings.Split(sr.ByPath, PathDelimiter)
traversedElems := strings.Split(strings.Trim(yamlPath, PathDelimiter), PathDelimiter)
if len(inputElems) != len(traversedElems) {
return false

patternElems := strings.Split(sr.ByPath, PathDelimiter)
yamlPathElems := strings.Split(strings.TrimPrefix(yamlPath, PathDelimiter), PathDelimiter)
return backTrackMatch(yamlPathElems, patternElems)
}

// backTrackMatch matches the traversed yamlPathElems with input(from by-path) patternElems
// * matches any element, ** matches 0 or more elements, array elements are split and matched
// refer to pathparser_test.go
func backTrackMatch(yamlPathElems, patternElems []string) bool {
// this is a dynamic programming problem
// aim is to check if path array matches pattern array as per above rules
yamlPathElemsLen, patternElemsLen := len(yamlPathElems), len(patternElems)

// initialize a 2d boolean matrix to memorize results
// dp[i][j] stores the result, if yamlPath subarray of length i matches
// pattern subarray of length j
dp := make([][]bool, yamlPathElemsLen+1)
for i := range dp {
dp[i] = make([]bool, patternElemsLen+1)
}
dp[0][0] = true

// edge case 1: when pattern is empty, yamlPath of length grater than 0 doesn't match
for i := 1; i < yamlPathElemsLen+1; i++ {
dp[i][0] = false
}

// edge case 2: if yamlPath is empty, carry forward the previous result if the pattern element
// is `**` as it matches 0 or more elements.
for j := 1; j < patternElemsLen+1; j++ {
if patternElems[j-1] == "**" {
dp[0][j] = dp[0][j-1]
}
}
for i, inputElem := range inputElems {
if inputElem != "*" && inputElem != traversedElems[i] {

// fill rest of the matrix
for i := 1; i < yamlPathElemsLen+1; i++ {
for j := 1; j < patternElemsLen+1; j++ {
if patternElems[j-1] == "**" {
// `**` matches multiple elements, so carry forward the result from immediate
// neighbors, dp[i-1][j] match empty, dp[i][j-1] match multiple elements
dp[i][j] = dp[i][j-1] || dp[i-1][j]
} else if patternElems[j-1] == "*" || elementMatch(yamlPathElems[i-1], patternElems[j-1]) {
// if there is element match or `*` then get the result from previous diagonal element
dp[i][j] = dp[i-1][j-1]
}
}
}

/*Example matrix for yamlPath = [a,a,b,c,e,b] and pattern [a,*,b,**,b]
a a b c e b
a T F F F F F
* F T F F F F
b F F T F F F
** F F T T T T
b F F F F F T
*/

return dp[yamlPathElemsLen][patternElemsLen]
}

// elementMatch matches single element with pattern for single element
func elementMatch(elem, pattern string) bool {
// scalar field case `metadata` matches `metadata`
if elem == pattern {
return true
}
// array element e.g. a[*], *[*] and *[b] matches a[b]
if strings.Contains(elem, "[") {
elemParts := strings.Split(elem, "[")
patternParts := strings.Split(pattern, "[")
if patternParts[0] != "*" && elemParts[0] != patternParts[0] {
return false
}
return patternParts[1] == "*]" || elemParts[1] == patternParts[1]
}
return true
return false
}

// isAbsPath checks if input path is absolute and not a path expression
Expand Down
24 changes: 24 additions & 0 deletions internal/util/search/pathparser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ var tests = []test{
traversedPath: "a.b.c.d",
shouldMatch: true,
},
{
name: "simple path match with **",
byPath: "a.**.c.*.d",
traversedPath: "a.b.c.c.d",
shouldMatch: true,
},
{
name: "simple path no match with *",
byPath: "a.*.c.*",
Expand All @@ -44,6 +50,24 @@ var tests = []test{
traversedPath: "a.c[0]",
shouldMatch: true,
},
{
name: "array path match regex",
byPath: "a.c[*].d.*[*].f",
traversedPath: "a.c[0].d.e[1].f",
shouldMatch: true,
},
{
name: "complex path match regex",
byPath: "**.c[*].d.*[*].**.f",
traversedPath: "a.b.c[0].d.e[1].f",
shouldMatch: true,
},
{
name: "complex path no match regex",
byPath: "**.c[*].d.d.*[*].**.f",
traversedPath: "a.c[2].c[0].d.e[1].f",
shouldMatch: false,
},
}

func TestPathMatch(t *testing.T) {
Expand Down
7 changes: 5 additions & 2 deletions internal/util/search/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,6 @@ func (sr *SearchReplace) matchAndReplace(node *yaml.Node, path string) error {
pathMatch := sr.pathMatch(path)
valueMatch := node.Value == sr.ByValue || sr.regexMatch(node.Value)

// at least one of path or value must be matched
if (valueMatch && pathMatch) || (valueMatch && sr.ByPath == "") ||
(pathMatch && sr.ByValue == "" && sr.ByValueRegex == "") {
sr.Count++
Expand Down Expand Up @@ -192,7 +191,11 @@ func (sr *SearchReplace) putLiteral(object *yaml.RNode) error {
// so that the value can be directly put without needing to traverse the entire node,
// handles the case of adding non-existent field-value to node
func (sr *SearchReplace) shouldPutLiteralByPath() bool {
return isAbsPath(sr.ByPath) && sr.ByValue == "" && sr.ByValueRegex == "" && sr.PutLiteral != ""
return isAbsPath(sr.ByPath) &&
!strings.Contains(sr.ByPath, "[") && // TODO: pmarupaka Support appending literal for arrays
sr.ByValue == "" &&
sr.ByValueRegex == "" &&
sr.PutLiteral != ""
}

// settersValues returns the values for the setters present in PutPattern,
Expand Down

0 comments on commit d0762af

Please sign in to comment.