implements nested structure replacements

This commit is contained in:
koba1t
2025-08-28 05:32:35 +09:00
parent 2dc0d0da8b
commit bbe53c2c45
5 changed files with 928 additions and 14 deletions

View File

@@ -164,6 +164,11 @@ func (p *PathMatcher) doField(rn *RNode) (*RNode, error) {
}
}
// Check if the field is a scalar and there are remaining path segments
if field != nil && field.YNode().Kind == yaml.ScalarNode && len(p.Path) > 1 {
return p.handleStructuredDataInScalar(field)
}
// recurse on the field, removing the first element of the path
pm := &PathMatcher{Path: p.Path[1:], Create: p.Create}
p.val, err = pm.filter(field)
@@ -263,12 +268,12 @@ func (p *PathMatcher) doSeq(rn *RNode) (*RNode, error) {
func (p *PathMatcher) visitPrimitiveElem(elem *RNode) error {
r, err := regexp.Compile(p.matchRegex)
if err != nil {
return err
return fmt.Errorf("%w", err)
}
str, err := elem.String()
if err != nil {
return err
return fmt.Errorf("%w", err)
}
str = strings.TrimSpace(str)
if !r.MatchString(str) {
@@ -282,7 +287,7 @@ func (p *PathMatcher) visitPrimitiveElem(elem *RNode) error {
func (p *PathMatcher) visitElem(elem *RNode) error {
r, err := regexp.Compile(p.matchRegex)
if err != nil {
return err
return fmt.Errorf("%w", err)
}
// check if this elements field matches the regex
@@ -292,7 +297,7 @@ func (p *PathMatcher) visitElem(elem *RNode) error {
}
str, err := val.Value.String()
if err != nil {
return err
return fmt.Errorf("%w", err)
}
str = strings.TrimSpace(str)
if !r.MatchString(str) {
@@ -341,3 +346,69 @@ func cleanPath(path []string) []string {
}
return p
}
// structuredDataNode is a wrapper around RNode that handles serialization back to scalar fields
// when the underlying structured data is modified
type structuredDataNode struct {
RNode
structuredRoot *RNode // The root of the structured data
scalarField *RNode // The original scalar field containing the structured data
}
// Override SetYNode to handle structured data serialization
func (s *structuredDataNode) SetYNode(node *yaml.Node) {
// Call the original SetYNode
s.RNode.SetYNode(node)
// Serialize the modified structured data back to the scalar field
if modifiedData, err := s.structuredRoot.String(); err == nil {
s.scalarField.YNode().Value = strings.TrimSpace(modifiedData)
}
}
// handleStructuredDataInScalar processes a scalar field that contains structured data (JSON/YAML)
// and allows path navigation within that structured data
func (p *PathMatcher) handleStructuredDataInScalar(scalarField *RNode) (*RNode, error) {
scalarValue := scalarField.YNode().Value
var parsedNode yaml.Node
if err := yaml.Unmarshal([]byte(scalarValue), &parsedNode); err != nil {
return nil, fmt.Errorf("%w", err)
}
// Create a structured field from the parsed data
structuredField := NewRNode(&parsedNode)
// Process the remaining path on the structured data
pm := &PathMatcher{Path: p.Path[1:], Create: p.Create}
result, err := pm.filter(structuredField)
if err != nil {
return nil, err
}
p.Matches = pm.Matches
// For structured data, we need to create a special result node that knows how to
// serialize back to the original scalar when modified
if result != nil && len(result.YNode().Content) > 0 {
// Wrap the result with structured data context
for _, node := range result.YNode().Content {
wrappedNode := NewRNode(node)
// Add a custom method to handle setting values back to structured data
wrappedNode = p.wrapWithStructuredDataHandler(wrappedNode, structuredField, scalarField)
result.YNode().Content[0] = wrappedNode.YNode()
}
}
return result, nil
}
// wrapWithStructuredDataHandler creates a wrapper that will serialize structured data back
// to the scalar field when the node is modified
func (p *PathMatcher) wrapWithStructuredDataHandler(targetNode, structuredRoot, scalarField *RNode) *RNode {
// Create a custom node that overrides SetYNode to handle structured data serialization
wrapper := &structuredDataNode{
RNode: *targetNode,
structuredRoot: structuredRoot,
scalarField: scalarField,
}
return &wrapper.RNode
}

View File

@@ -287,3 +287,236 @@ spec:
})
}
}
func TestPathMatcher_StructuredDataInScalar(t *testing.T) {
testCases := map[string]struct {
input string
path []string
expected string
expectError bool
}{
"json field access": {
input: `apiVersion: v1
kind: ConfigMap
metadata:
name: test-config
data:
config.json: |-
{
"database": {
"host": "localhost",
"port": 5432
},
"app": {
"name": "myapp",
"version": "1.0.0"
}
}`,
path: []string{"data", "config.json", "database", "host"},
expected: "- \"localhost\"\n",
},
"json nested field access": {
input: `apiVersion: v1
kind: ConfigMap
metadata:
name: test-config
data:
config.json: |-
{
"database": {
"host": "localhost",
"port": 5432
},
"app": {
"name": "myapp",
"version": "1.0.0"
}
}`,
path: []string{"data", "config.json", "app", "name"},
expected: "- \"myapp\"\n",
},
"yaml field access": {
input: `apiVersion: v1
kind: ConfigMap
metadata:
name: prometheus-config
data:
prometheus.yml: |-
global:
external_labels:
prometheus_env: dev
cluster: local
scrape_configs:
- job_name: "prometheus"
static_configs:
- targets: ["localhost:9090"]`,
path: []string{"data", "prometheus.yml", "global", "external_labels", "prometheus_env"},
expected: "- dev\n",
},
"yaml array access": {
input: `apiVersion: v1
kind: ConfigMap
metadata:
name: deployment-config
data:
deployment.yaml: |-
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
spec:
replicas: 3
template:
spec:
containers:
- name: web
image: nginx:1.18
- name: sidecar
image: busybox:latest`,
path: []string{"data", "deployment.yaml", "spec", "replicas"},
expected: "- 3\n",
},
"yaml container array with selector": {
input: `apiVersion: v1
kind: ConfigMap
metadata:
name: deployment-config
data:
deployment.yaml: |-
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
spec:
template:
spec:
containers:
- name: web
image: nginx:1.18
- name: sidecar
image: busybox:latest`,
path: []string{"data", "deployment.yaml", "spec", "template", "spec", "containers", "[name=web]", "image"},
expected: "- nginx:1.18\n",
},
"json complex nested structure": {
input: `apiVersion: v1
kind: ConfigMap
metadata:
name: complex-config
data:
config.json: |-
{
"database": {
"connections": {
"primary": {
"host": "primary-db.example.com",
"port": 5432,
"ssl": true
},
"secondary": {
"host": "secondary-db.example.com",
"port": 5433
}
}
}
}`,
path: []string{"data", "config.json", "database", "connections", "primary", "host"},
expected: "- \"primary-db.example.com\"\n",
},
"yaml flow style (kyaml) field access": {
input: `apiVersion: v1
kind: ConfigMap
metadata:
name: test-config
data:
config.yaml: |-
labels: {
app: "foobar",
foo: "bar",
something: "12345",
}
spec: {
replicas: 3,
selector: {
matchLabels: {
app: "foobar"
}
}
}`,
path: []string{"data", "config.yaml", "labels", "app"},
expected: "- \"foobar\"\n",
},
"yaml flow style nested field access": {
input: `apiVersion: v1
kind: ConfigMap
metadata:
name: test-config
data:
config.yaml: |-
labels: {
app: "foobar",
foo: "bar",
something: "12345",
}
spec: {
replicas: 3,
selector: {
matchLabels: {
app: "foobar"
}
}
}`,
path: []string{"data", "config.yaml", "spec", "selector", "matchLabels", "app"},
expected: "- \"foobar\"\n",
},
"invalid json returns field value as-is": {
input: `apiVersion: v1
kind: ConfigMap
metadata:
name: bad-config
data:
config.json: |-
{
"invalid": json
}`,
path: []string{"data", "config.json"},
expected: "- |-\n {\n \"invalid\": json\n }\n",
},
"access non-existent field": {
input: `apiVersion: v1
kind: ConfigMap
metadata:
name: test-config
data:
config.json: |-
{
"existing": "value"
}`,
path: []string{"data", "config.json", "nonexistent"},
expected: "",
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
node := MustParse(tc.input)
result, err := node.Pipe(&PathMatcher{Path: tc.path})
if tc.expectError {
assert.Error(t, err)
return
}
if !assert.NoError(t, err) {
return
}
if tc.expected == "" {
assert.True(t, result == nil || result.IsNil() || result.MustString() == "")
return
}
assert.Equal(t, tc.expected, result.MustString())
})
}
}