diff --git a/README.md b/README.md index ebf91b5..374c319 100644 --- a/README.md +++ b/README.md @@ -280,6 +280,133 @@ $.person.*~ --- +## Overlay Support + +This library includes support for YAML overlays, which allow you to apply structured modifications to YAML documents using JSONPath expressions. + +### Basic Overlay Usage + +Overlays are defined in YAML format and specify actions to apply to a target document: + +```yaml +overlay: "1.0.0" +info: + title: My Overlay + version: "1.0" +actions: + - target: $.spec.replicas + update: 3 +``` + +```go +package main + +import ( + "fmt" + "github.com/pb33f/jsonpath/pkg/overlay" + "go.yaml.in/yaml/v4" +) + +func main() { + // Parse the overlay + ov, _ := overlay.ParseOverlay(overlayYAML) + + // Apply to a document + result, _ := ov.Apply(documentNode) +} +``` + +### Upsert Action + +The `upsert` action combines update and insert behavior. When `upsert: true` is set: + +- If the target path exists, the value is updated (same as `update`) +- If the target path doesn't exist, the path is created and the value is set + +**Example: Create nested paths** + +```yaml +# Input document +spec: + existing: value + +# Overlay +overlay: "1.0.0" +actions: + - target: $.spec.config.nested.key + update: created + upsert: true + +# Result +spec: + existing: value + config: + nested: + key: created +``` + +**Example: Update existing values** + +```yaml +# Input document +spec: + existing: value + +# Overlay +overlay: "1.0.0" +actions: + - target: $.spec.existing + update: updated + upsert: true + +# Result +spec: + existing: updated +``` + +**Example: Array elements** + +```yaml +# Input document +items: + - name: first + +# Overlay +overlay: "1.0.0" +actions: + - target: $.items[0].name + update: updated + upsert: true + +# Result +items: + - name: updated +``` + +### Supported Path Types for Upsert + +Upsert works with **singular paths** - paths that resolve to exactly one location: + +| Path Type | Example | Behavior | +|-----------|---------|----------| +| Member name | `$.foo.bar` | Creates nested maps as needed | +| Array index | `$.items[0]` | Creates arrays and sets at index | +| Combined | `$.a.b[2].c` | Creates nested structures | + +### Unsupported Path Types + +The following path types **cannot** be used with upsert (will return an error): + +| Path Type | Example | Reason | +|-----------|---------|--------| +| Wildcard | `$.*.foo` | Ambiguous - which child? | +| Recursive descent | `$..foo` | Ambiguous location | +| Filter | `$[?(@.x)]` | Query, not specific location | +| Multiple selectors | `$['a','b']` | Multiple locations | +| Slice | `$[0:5]` | Multiple locations | + +--- + ## Standard RFC 9535 Features This library fully implements RFC 9535, including: diff --git a/pkg/jsonpath/jsonpath.go b/pkg/jsonpath/jsonpath.go index 229dbeb..851256e 100644 --- a/pkg/jsonpath/jsonpath.go +++ b/pkg/jsonpath/jsonpath.go @@ -1,35 +1,63 @@ package jsonpath import ( - "fmt" - "github.com/pb33f/jsonpath/pkg/jsonpath/config" - "github.com/pb33f/jsonpath/pkg/jsonpath/token" - "go.yaml.in/yaml/v4" + "fmt" + "github.com/pb33f/jsonpath/pkg/jsonpath/config" + "github.com/pb33f/jsonpath/pkg/jsonpath/token" + "go.yaml.in/yaml/v4" ) func NewPath(input string, opts ...config.Option) (*JSONPath, error) { - tokenizer := token.NewTokenizer(input, opts...) - tokens := tokenizer.Tokenize() - for i := 0; i < len(tokens); i++ { - if tokens[i].Token == token.ILLEGAL { - return nil, fmt.Errorf("%s", tokenizer.ErrorString(&tokens[i], "unexpected token")) - } - } - parser := newParserPrivate(tokenizer, tokens, opts...) - err := parser.parse() - if err != nil { - return nil, err - } - return parser, nil + tokenizer := token.NewTokenizer(input, opts...) + tokens := tokenizer.Tokenize() + for i := 0; i < len(tokens); i++ { + if tokens[i].Token == token.ILLEGAL { + return nil, fmt.Errorf("%s", tokenizer.ErrorString(&tokens[i], "unexpected token")) + } + } + parser := newParserPrivate(tokenizer, tokens, opts...) + err := parser.parse() + if err != nil { + return nil, err + } + return parser, nil } func (p *JSONPath) Query(root *yaml.Node) []*yaml.Node { - return p.ast.Query(root, root) + return p.ast.Query(root, root) } func (p *JSONPath) String() string { - if p == nil { - return "" - } - return p.ast.ToString() + if p == nil { + return "" + } + return p.ast.ToString() +} + +func (p *JSONPath) IsSingular() bool { + if p == nil { + return false + } + return p.ast.isSingular() +} + +type SegmentInfo struct { + Kind SegmentKind + Key string + Index int64 + HasIndex bool +} + +type SegmentKind int + +const ( + SegmentKindMemberName SegmentKind = iota + SegmentKindArrayIndex +) + +func (p *JSONPath) GetSegmentInfo() ([]SegmentInfo, error) { + if p == nil { + return nil, fmt.Errorf("nil path") + } + return p.ast.getSegmentInfo() } diff --git a/pkg/jsonpath/parser_test.go b/pkg/jsonpath/parser_test.go index 717504b..15b4279 100644 --- a/pkg/jsonpath/parser_test.go +++ b/pkg/jsonpath/parser_test.go @@ -1,191 +1,273 @@ package jsonpath_test import ( - "github.com/pb33f/jsonpath/pkg/jsonpath" - "github.com/pb33f/jsonpath/pkg/jsonpath/config" - "github.com/stretchr/testify/require" - "testing" + "github.com/pb33f/jsonpath/pkg/jsonpath" + "github.com/pb33f/jsonpath/pkg/jsonpath/config" + "github.com/stretchr/testify/require" + "testing" ) func TestParser(t *testing.T) { - tests := []struct { - name string - input string - invalid bool - }{ - { - name: "Root node", - input: "$", - }, - { - name: "Single Dot child", - input: "$.store", - }, - { - name: "Single Bracket child", - input: "$['store']", - }, - { - name: "Bracket child", - input: "$['store']['book']", - }, - { - name: "Array index", - input: "$[0]", - }, - { - name: "Array slice", - input: "$[1:3]", - }, - { - name: "Array slice with step", - input: "$[0:5:2]", - }, - { - name: "Array slice with negative step", - input: "$[5:1:-2]", - }, - { - name: "Filter expression", - input: "$[?(@.price < 10)]", - }, - { - name: "Nested filter expression", - input: "$[?(@.price < 10 && @.category == 'fiction')]", - }, - { - name: "Function call", - input: "$.books[?(length(@) > 100)]", - }, - { - name: "Invalid missing closing ]", - input: "$.paths.['/pet'", - invalid: true, - }, - { - name: "Invalid extra input", - input: "$.paths.['/pet')", - invalid: true, - }, - { - name: "Valid filter", - input: "$.paths[?(1 == 1)]", - }, - { - name: "Invalid filter", - input: "$.paths[?(true]", - invalid: true, - }, - } + tests := []struct { + name string + input string + invalid bool + }{ + { + name: "Root node", + input: "$", + }, + { + name: "Single Dot child", + input: "$.store", + }, + { + name: "Single Bracket child", + input: "$['store']", + }, + { + name: "Bracket child", + input: "$['store']['book']", + }, + { + name: "Array index", + input: "$[0]", + }, + { + name: "Array slice", + input: "$[1:3]", + }, + { + name: "Array slice with step", + input: "$[0:5:2]", + }, + { + name: "Array slice with negative step", + input: "$[5:1:-2]", + }, + { + name: "Filter expression", + input: "$[?(@.price < 10)]", + }, + { + name: "Nested filter expression", + input: "$[?(@.price < 10 && @.category == 'fiction')]", + }, + { + name: "Function call", + input: "$.books[?(length(@) > 100)]", + }, + { + name: "Invalid missing closing ]", + input: "$.paths.['/pet'", + invalid: true, + }, + { + name: "Invalid extra input", + input: "$.paths.['/pet')", + invalid: true, + }, + { + name: "Valid filter", + input: "$.paths[?(1 == 1)]", + }, + { + name: "Invalid filter", + input: "$.paths[?(true]", + invalid: true, + }, + } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - path, err := jsonpath.NewPath(test.input) - if test.invalid { - require.Error(t, err) - return - } - require.NoError(t, err) - require.Equal(t, test.input, path.String()) - }) - } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + path, err := jsonpath.NewPath(test.input) + if test.invalid { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, test.input, path.String()) + }) + } } func TestParserPropertyNameExtension(t *testing.T) { - tests := []struct { - name string - input string - enabled bool - valid bool - }{ - { - name: "Simple property name disabled", - input: "$.store~", - enabled: false, - valid: false, - }, - { - name: "Simple property name enabled", - input: "$.store~", - enabled: true, - valid: true, - }, - { - name: "Property name in filter disabled", - input: "$[?(@~)]", - enabled: false, - valid: false, - }, - { - name: "Property name in filter enabled", - input: "$[?(@~)]", - enabled: true, - valid: true, - }, - { - name: "Property name with bracket notation enabled", - input: "$['store']~", - enabled: true, - valid: true, - }, - { - name: "Property name with bracket notation disabled", - input: "$['store']~", - enabled: false, - valid: false, - }, - { - name: "Chained property names enabled", - input: "$.store~.name~", - enabled: true, - valid: true, - }, - { - name: "Property name in complex filter enabled", - input: "$[?(@~ && @.price < 10)]", - enabled: true, - valid: true, - }, - { - name: "Property name in complex filter disabled", - input: "$[?(@~ && @.price < 10)]", - enabled: false, - valid: false, - }, - { - name: "Missing closing a filter expression shouldn't crash", - input: "$.paths.*.*[?(!@.servers)", - enabled: false, - valid: false, - }, - { - name: "Missing closing a filter expression shouldn't crash", - input: "$.paths.*.*[?(!@.servers)", - enabled: false, - valid: false, - }, - { - name: "Missing closing a array crash", - input: "$.paths.*[?@[\"x-my-ignore\"]", - enabled: false, - valid: false, - }, - } + tests := []struct { + name string + input string + enabled bool + valid bool + }{ + { + name: "Simple property name disabled", + input: "$.store~", + enabled: false, + valid: false, + }, + { + name: "Simple property name enabled", + input: "$.store~", + enabled: true, + valid: true, + }, + { + name: "Property name in filter disabled", + input: "$[?(@~)]", + enabled: false, + valid: false, + }, + { + name: "Property name in filter enabled", + input: "$[?(@~)]", + enabled: true, + valid: true, + }, + { + name: "Property name with bracket notation enabled", + input: "$['store']~", + enabled: true, + valid: true, + }, + { + name: "Property name with bracket notation disabled", + input: "$['store']~", + enabled: false, + valid: false, + }, + { + name: "Chained property names enabled", + input: "$.store~.name~", + enabled: true, + valid: true, + }, + { + name: "Property name in complex filter enabled", + input: "$[?(@~ && @.price < 10)]", + enabled: true, + valid: true, + }, + { + name: "Property name in complex filter disabled", + input: "$[?(@~ && @.price < 10)]", + enabled: false, + valid: false, + }, + { + name: "Missing closing a filter expression shouldn't crash", + input: "$.paths.*.*[?(!@.servers)", + enabled: false, + valid: false, + }, + { + name: "Missing closing a filter expression shouldn't crash", + input: "$.paths.*.*[?(!@.servers)", + enabled: false, + valid: false, + }, + { + name: "Missing closing a array crash", + input: "$.paths.*[?@[\"x-my-ignore\"]", + enabled: false, + valid: false, + }, + } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - var opts []config.Option - if test.enabled { - opts = append(opts, config.WithPropertyNameExtension()) - } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var opts []config.Option + if test.enabled { + opts = append(opts, config.WithPropertyNameExtension()) + } - path, err := jsonpath.NewPath(test.input, opts...) - if !test.valid { - require.Error(t, err) - return - } - require.NoError(t, err) - require.Equal(t, test.input, path.String()) - }) - } + path, err := jsonpath.NewPath(test.input, opts...) + if !test.valid { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, test.input, path.String()) + }) + } +} + +func TestIsSingular(t *testing.T) { + tests := []struct { + name string + input string + singular bool + }{ + { + name: "root only", + input: "$", + singular: true, + }, + { + name: "single member", + input: "$.foo", + singular: true, + }, + { + name: "nested members", + input: "$.foo.bar.baz", + singular: true, + }, + { + name: "bracket member name", + input: "$['foo']", + singular: true, + }, + { + name: "array index", + input: "$.items[0]", + singular: true, + }, + { + name: "nested with array", + input: "$.items[0].name", + singular: true, + }, + { + name: "wildcard", + input: "$.*", + singular: false, + }, + { + name: "wildcard nested", + input: "$.foo.*", + singular: false, + }, + { + name: "recursive descent", + input: "$..foo", + singular: false, + }, + { + name: "array slice", + input: "$[0:5]", + singular: false, + }, + { + name: "filter", + input: "$[?(@.x)]", + singular: false, + }, + { + name: "multiple selectors", + input: "$['foo','bar']", + singular: false, + }, + { + name: "negative array index", + input: "$.items[-1]", + singular: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + path, err := jsonpath.NewPath(test.input) + require.NoError(t, err) + require.Equal(t, test.singular, path.IsSingular(), "IsSingular() for %s", test.input) + }) + } } diff --git a/pkg/jsonpath/segment.go b/pkg/jsonpath/segment.go index c698b21..b80bb76 100644 --- a/pkg/jsonpath/segment.go +++ b/pkg/jsonpath/segment.go @@ -1,88 +1,185 @@ package jsonpath import ( - "go.yaml.in/yaml/v4" - "strings" + "fmt" + "go.yaml.in/yaml/v4" + "strings" ) type segmentKind int const ( - segmentKindChild segmentKind = iota // . - segmentKindDescendant // .. - segmentKindProperyName // ~ (extension only) - segmentKindParent // ^ (JSONPath Plus parent selector) + segmentKindChild segmentKind = iota // . + segmentKindDescendant // .. + segmentKindProperyName // ~ (extension only) + segmentKindParent // ^ (JSONPath Plus parent selector) ) type segment struct { - kind segmentKind - child *innerSegment - descendant *innerSegment + kind segmentKind + child *innerSegment + descendant *innerSegment } type segmentSubKind int const ( - segmentDotWildcard segmentSubKind = iota // .* - segmentDotMemberName // .property - segmentLongHand // [ selector[] ] + segmentDotWildcard segmentSubKind = iota // .* + segmentDotMemberName // .property + segmentLongHand // [ selector[] ] ) func (s segment) ToString() string { - switch s.kind { - case segmentKindChild: - if s.child.kind != segmentLongHand { - return "." + s.child.ToString() - } else { - return s.child.ToString() - } - case segmentKindDescendant: - return ".." + s.descendant.ToString() - case segmentKindProperyName: - return "~" - case segmentKindParent: - return "^" - } - panic("unknown segment kind") + switch s.kind { + case segmentKindChild: + if s.child.kind != segmentLongHand { + return "." + s.child.ToString() + } else { + return s.child.ToString() + } + case segmentKindDescendant: + return ".." + s.descendant.ToString() + case segmentKindProperyName: + return "~" + case segmentKindParent: + return "^" + } + panic("unknown segment kind") } type innerSegment struct { - kind segmentSubKind - dotName string - selectors []*selector + kind segmentSubKind + dotName string + selectors []*selector } func (s innerSegment) ToString() string { - builder := strings.Builder{} - switch s.kind { - case segmentDotWildcard: - builder.WriteString("*") - break - case segmentDotMemberName: - builder.WriteString(s.dotName) - break - case segmentLongHand: - builder.WriteString("[") - for i, selector := range s.selectors { - builder.WriteString(selector.ToString()) - if i < len(s.selectors)-1 { - builder.WriteString(", ") - } - } - builder.WriteString("]") - break - default: - panic("unknown child segment kind") - } - return builder.String() + builder := strings.Builder{} + switch s.kind { + case segmentDotWildcard: + builder.WriteString("*") + break + case segmentDotMemberName: + builder.WriteString(s.dotName) + break + case segmentLongHand: + builder.WriteString("[") + for i, selector := range s.selectors { + builder.WriteString(selector.ToString()) + if i < len(s.selectors)-1 { + builder.WriteString(", ") + } + } + builder.WriteString("]") + break + default: + panic("unknown child segment kind") + } + return builder.String() } func descendApply(value *yaml.Node, apply func(*yaml.Node)) { - if value == nil { - return - } - apply(value) - for _, child := range value.Content { - descendApply(child, apply) - } + if value == nil { + return + } + apply(value) + for _, child := range value.Content { + descendApply(child, apply) + } +} + +func (s segment) IsSingular() bool { + switch s.kind { + case segmentKindDescendant: + return false + case segmentKindParent: + return true + case segmentKindProperyName: + return false + case segmentKindChild: + if s.child == nil { + return false + } + return s.child.IsSingular() + default: + return false + } +} + +func (s innerSegment) IsSingular() bool { + switch s.kind { + case segmentDotWildcard: + return false + case segmentDotMemberName: + return true + case segmentLongHand: + if len(s.selectors) != 1 { + return false + } + return s.selectors[0].IsSingular() + default: + return false + } +} + +func (s selector) IsSingular() bool { + switch s.kind { + case selectorSubKindName: + return true + case selectorSubKindArrayIndex: + return true + case selectorSubKindWildcard: + return false + case selectorSubKindArraySlice: + return false + case selectorSubKindFilter: + return false + default: + return false + } +} + +func (s segment) getSegmentInfo() ([]SegmentInfo, error) { + switch s.kind { + case segmentKindChild: + if s.child == nil { + return nil, fmt.Errorf("nil child segment") + } + return s.child.getSegmentInfo() + case segmentKindDescendant: + return nil, fmt.Errorf("recursive descent not supported for upsert") + case segmentKindParent, segmentKindProperyName: + return nil, fmt.Errorf("parent/property selectors not supported for upsert") + default: + return nil, fmt.Errorf("unknown segment kind") + } +} + +func (s innerSegment) getSegmentInfo() ([]SegmentInfo, error) { + switch s.kind { + case segmentDotMemberName: + return []SegmentInfo{{Kind: SegmentKindMemberName, Key: s.dotName}}, nil + case segmentLongHand: + if len(s.selectors) != 1 { + return nil, fmt.Errorf("multiple selectors not supported for upsert") + } + return s.selectors[0].getSegmentInfo() + case segmentDotWildcard: + return nil, fmt.Errorf("wildcard not supported for upsert") + default: + return nil, fmt.Errorf("unknown inner segment kind") + } +} + +func (s selector) getSegmentInfo() ([]SegmentInfo, error) { + switch s.kind { + case selectorSubKindName: + return []SegmentInfo{{Kind: SegmentKindMemberName, Key: s.name}}, nil + case selectorSubKindArrayIndex: + return []SegmentInfo{{Kind: SegmentKindArrayIndex, Index: s.index, HasIndex: true}}, nil + case selectorSubKindWildcard, selectorSubKindArraySlice, selectorSubKindFilter: + return nil, fmt.Errorf("%v selector not supported for upsert", s.kind) + default: + return nil, fmt.Errorf("unknown selector kind") + } } diff --git a/pkg/jsonpath/yaml_query.go b/pkg/jsonpath/yaml_query.go index d597a60..62f27e2 100644 --- a/pkg/jsonpath/yaml_query.go +++ b/pkg/jsonpath/yaml_query.go @@ -102,6 +102,27 @@ func (q jsonPathAST) Query(current *yaml.Node, root *yaml.Node) []*yaml.Node { return result } +func (q jsonPathAST) isSingular() bool { + for _, seg := range q.segments { + if !seg.IsSingular() { + return false + } + } + return true +} + +func (q jsonPathAST) getSegmentInfo() ([]SegmentInfo, error) { + result := make([]SegmentInfo, 0, len(q.segments)) + for _, seg := range q.segments { + info, err := seg.getSegmentInfo() + if err != nil { + return nil, err + } + result = append(result, info...) + } + return result, nil +} + // hasParentReferences checks if the AST uses parent selectors (^) or @parent context variable func (q jsonPathAST) hasParentReferences() bool { for _, seg := range q.segments { @@ -245,539 +266,539 @@ func enableIndexTracking(ctx FilterContext) { } func (s segment) Query(idx index, value *yaml.Node, root *yaml.Node) []*yaml.Node { - switch s.kind { - case segmentKindChild: - return s.child.Query(idx, value, root) - case segmentKindDescendant: - // run the inner segment against this node - var result = []*yaml.Node{} - descendApply(value, func(child *yaml.Node) { - result = append(result, s.descendant.Query(idx, child, root)...) - }) - // make children unique by pointer value - result = unique(result) - return result - case segmentKindProperyName: - found := idx.getPropertyKey(value) - if found != nil { - return []*yaml.Node{found} - } - return []*yaml.Node{} - case segmentKindParent: - // JSONPath Plus parent selector: ^ returns the parent of the current node - parent := idx.getParentNode(value) - if parent != nil { - return []*yaml.Node{parent} - } - // No parent found (could be root node) - return []*yaml.Node{} - } - panic("no segment type") + switch s.kind { + case segmentKindChild: + return s.child.Query(idx, value, root) + case segmentKindDescendant: + // run the inner segment against this node + var result = []*yaml.Node{} + descendApply(value, func(child *yaml.Node) { + result = append(result, s.descendant.Query(idx, child, root)...) + }) + // make children unique by pointer value + result = unique(result) + return result + case segmentKindProperyName: + found := idx.getPropertyKey(value) + if found != nil { + return []*yaml.Node{found} + } + return []*yaml.Node{} + case segmentKindParent: + // JSONPath Plus parent selector: ^ returns the parent of the current node + parent := idx.getParentNode(value) + if parent != nil { + return []*yaml.Node{parent} + } + // No parent found (could be root node) + return []*yaml.Node{} + } + panic("no segment type") } func unique(nodes []*yaml.Node) []*yaml.Node { - // stably returns a new slice containing only the unique elements from nodes - res := make([]*yaml.Node, 0) - seen := make(map[*yaml.Node]bool) - for _, node := range nodes { - if _, ok := seen[node]; !ok { - res = append(res, node) - seen[node] = true - } - } - return res + // stably returns a new slice containing only the unique elements from nodes + res := make([]*yaml.Node, 0) + seen := make(map[*yaml.Node]bool) + for _, node := range nodes { + if _, ok := seen[node]; !ok { + res = append(res, node) + seen[node] = true + } + } + return res } func (s innerSegment) Query(idx index, value *yaml.Node, root *yaml.Node) []*yaml.Node { - result := []*yaml.Node{} - trackParents := parentTrackingEnabled(idx) - - switch s.kind { - case segmentDotWildcard: - // Check for inherited pending segment from previous wildcard/slice - var inheritedPending string - if fc, ok := idx.(FilterContext); ok { - inheritedPending = fc.GetAndClearPendingPathSegment(value) - } - - switch value.Kind { - case yaml.MappingNode: - for i, child := range value.Content { - if i%2 == 1 { - keyNode := value.Content[i-1] - idx.setPropertyKey(keyNode, value) - idx.setPropertyKey(child, keyNode) - if trackParents { - idx.setParentNode(child, value) - } - // Track pending path segment and property name for this node - if fc, ok := idx.(FilterContext); ok { - thisSegment := normalizePathSegment(keyNode.Value) - fc.SetPendingPathSegment(child, inheritedPending+thisSegment) - fc.SetPendingPropertyName(child, keyNode.Value) // For @parentProperty - } - result = append(result, child) - } - } - case yaml.SequenceNode: - for i, child := range value.Content { - if trackParents { - idx.setParentNode(child, value) - } - // Track pending path segment and property name for this node - if fc, ok := idx.(FilterContext); ok { - thisSegment := normalizeIndexSegment(i) - fc.SetPendingPathSegment(child, inheritedPending+thisSegment) - fc.SetPendingPropertyName(child, strconv.Itoa(i)) // For @parentProperty (array index as string) - } - result = append(result, child) - } - } - return result - case segmentDotMemberName: - if value.Kind == yaml.MappingNode { - // Check for inherited pending segment from wildcard/slice - var inheritedPending string - if fc, ok := idx.(FilterContext); ok { - inheritedPending = fc.GetAndClearPendingPathSegment(value) - } - - for i := 0; i < len(value.Content); i += 2 { - key := value.Content[i] - val := value.Content[i+1] - - if key.Value == s.dotName { - idx.setPropertyKey(key, value) - idx.setPropertyKey(val, key) - if trackParents { - idx.setParentNode(val, value) - } - if fc, ok := idx.(FilterContext); ok { - thisSegment := normalizePathSegment(key.Value) - if inheritedPending != "" { - // Propagate combined pending to result for later consumption - fc.SetPendingPathSegment(val, inheritedPending+thisSegment) - } else { - // No wildcard ancestry - push directly to path - fc.PushPathSegment(thisSegment) - } - fc.SetPropertyName(key.Value) - } - result = append(result, val) - break - } - } - } - - case segmentLongHand: - for _, selector := range s.selectors { - result = append(result, selector.Query(idx, value, root)...) - } - default: - panic("unknown child segment kind") - } - - return result + result := []*yaml.Node{} + trackParents := parentTrackingEnabled(idx) + + switch s.kind { + case segmentDotWildcard: + // Check for inherited pending segment from previous wildcard/slice + var inheritedPending string + if fc, ok := idx.(FilterContext); ok { + inheritedPending = fc.GetAndClearPendingPathSegment(value) + } + + switch value.Kind { + case yaml.MappingNode: + for i, child := range value.Content { + if i%2 == 1 { + keyNode := value.Content[i-1] + idx.setPropertyKey(keyNode, value) + idx.setPropertyKey(child, keyNode) + if trackParents { + idx.setParentNode(child, value) + } + // Track pending path segment and property name for this node + if fc, ok := idx.(FilterContext); ok { + thisSegment := normalizePathSegment(keyNode.Value) + fc.SetPendingPathSegment(child, inheritedPending+thisSegment) + fc.SetPendingPropertyName(child, keyNode.Value) // For @parentProperty + } + result = append(result, child) + } + } + case yaml.SequenceNode: + for i, child := range value.Content { + if trackParents { + idx.setParentNode(child, value) + } + // Track pending path segment and property name for this node + if fc, ok := idx.(FilterContext); ok { + thisSegment := normalizeIndexSegment(i) + fc.SetPendingPathSegment(child, inheritedPending+thisSegment) + fc.SetPendingPropertyName(child, strconv.Itoa(i)) // For @parentProperty (array index as string) + } + result = append(result, child) + } + } + return result + case segmentDotMemberName: + if value.Kind == yaml.MappingNode { + // Check for inherited pending segment from wildcard/slice + var inheritedPending string + if fc, ok := idx.(FilterContext); ok { + inheritedPending = fc.GetAndClearPendingPathSegment(value) + } + + for i := 0; i < len(value.Content); i += 2 { + key := value.Content[i] + val := value.Content[i+1] + + if key.Value == s.dotName { + idx.setPropertyKey(key, value) + idx.setPropertyKey(val, key) + if trackParents { + idx.setParentNode(val, value) + } + if fc, ok := idx.(FilterContext); ok { + thisSegment := normalizePathSegment(key.Value) + if inheritedPending != "" { + // Propagate combined pending to result for later consumption + fc.SetPendingPathSegment(val, inheritedPending+thisSegment) + } else { + // No wildcard ancestry - push directly to path + fc.PushPathSegment(thisSegment) + } + fc.SetPropertyName(key.Value) + } + result = append(result, val) + break + } + } + } + + case segmentLongHand: + for _, selector := range s.selectors { + result = append(result, selector.Query(idx, value, root)...) + } + default: + panic("unknown child segment kind") + } + + return result } func (s selector) Query(idx index, value *yaml.Node, root *yaml.Node) []*yaml.Node { - trackParents := parentTrackingEnabled(idx) - - switch s.kind { - case selectorSubKindName: - if value.Kind != yaml.MappingNode { - return nil - } - // Check for inherited pending segment from wildcard/slice - var inheritedPending string - if fc, ok := idx.(FilterContext); ok { - inheritedPending = fc.GetAndClearPendingPathSegment(value) - } - - var key string - for i, child := range value.Content { - if i%2 == 0 { - key = child.Value - continue - } - if key == s.name && i%2 == 1 { - idx.setPropertyKey(value.Content[i], value.Content[i-1]) - idx.setPropertyKey(value.Content[i-1], value) - if trackParents { - idx.setParentNode(child, value) - } - if fc, ok := idx.(FilterContext); ok { - thisSegment := normalizePathSegment(key) - if inheritedPending != "" { - // Propagate combined pending to result for later consumption - fc.SetPendingPathSegment(child, inheritedPending+thisSegment) - } else { - // No wildcard ancestry - push directly to path - fc.PushPathSegment(thisSegment) - } - fc.SetPropertyName(key) - } - return []*yaml.Node{child} - } - } - case selectorSubKindArrayIndex: - if value.Kind != yaml.SequenceNode { - return nil - } - if s.index >= int64(len(value.Content)) || s.index < -int64(len(value.Content)) { - return nil - } - // Check for inherited pending segment from wildcard/slice - var inheritedPending string - if fc, ok := idx.(FilterContext); ok { - inheritedPending = fc.GetAndClearPendingPathSegment(value) - } - - var child *yaml.Node - var actualIndex int - if s.index < 0 { - actualIndex = int(int64(len(value.Content)) + s.index) - child = value.Content[actualIndex] - } else { - actualIndex = int(s.index) - child = value.Content[s.index] - } - if trackParents { - idx.setParentNode(child, value) - } - if fc, ok := idx.(FilterContext); ok { - thisSegment := normalizeIndexSegment(actualIndex) - if inheritedPending != "" { - // Propagate combined pending to result for later consumption - fc.SetPendingPathSegment(child, inheritedPending+thisSegment) - } else { - // No wildcard ancestry - push directly to path - fc.PushPathSegment(thisSegment) - } - } - return []*yaml.Node{child} - case selectorSubKindWildcard: - // Check for inherited pending segment from previous wildcard/slice - var inheritedPending string - if fc, ok := idx.(FilterContext); ok { - inheritedPending = fc.GetAndClearPendingPathSegment(value) - } - - if value.Kind == yaml.SequenceNode { - for i, child := range value.Content { - if trackParents { - idx.setParentNode(child, value) - } - // Track pending path segment and property name for this node - if fc, ok := idx.(FilterContext); ok { - thisSegment := normalizeIndexSegment(i) - fc.SetPendingPathSegment(child, inheritedPending+thisSegment) - fc.SetPendingPropertyName(child, strconv.Itoa(i)) // For @parentProperty - } - } - return value.Content - } else if value.Kind == yaml.MappingNode { - var result []*yaml.Node - for i, child := range value.Content { - if i%2 == 1 { - keyNode := value.Content[i-1] - idx.setPropertyKey(keyNode, value) - idx.setPropertyKey(child, keyNode) - if trackParents { - idx.setParentNode(child, value) - } - // Track pending path segment and property name for this node - if fc, ok := idx.(FilterContext); ok { - thisSegment := normalizePathSegment(keyNode.Value) - fc.SetPendingPathSegment(child, inheritedPending+thisSegment) - fc.SetPendingPropertyName(child, keyNode.Value) // For @parentProperty - } - result = append(result, child) - } - } - return result - } - return nil - case selectorSubKindArraySlice: - if value.Kind != yaml.SequenceNode { - return nil - } - if len(value.Content) == 0 { - return nil - } - // Check for inherited pending segment from previous wildcard/slice - var inheritedPending string - if fc, ok := idx.(FilterContext); ok { - inheritedPending = fc.GetAndClearPendingPathSegment(value) - } - - step := int64(1) - if s.slice.step != nil { - step = *s.slice.step - } - if step == 0 { - return nil - } - - start, end := s.slice.start, s.slice.end - lower, upper := bounds(start, end, step, int64(len(value.Content))) - - var result []*yaml.Node - if step > 0 { - for i := lower; i < upper; i += step { - child := value.Content[i] - if trackParents { - idx.setParentNode(child, value) - } - // Track pending path segment and property name for this node - if fc, ok := idx.(FilterContext); ok { - thisSegment := normalizeIndexSegment(int(i)) - fc.SetPendingPathSegment(child, inheritedPending+thisSegment) - fc.SetPendingPropertyName(child, strconv.Itoa(int(i))) // For @parentProperty - } - result = append(result, child) - } - } else { - for i := upper; i > lower; i += step { - child := value.Content[i] - if trackParents { - idx.setParentNode(child, value) - } - // Track pending path segment and property name for this node - if fc, ok := idx.(FilterContext); ok { - thisSegment := normalizeIndexSegment(int(i)) - fc.SetPendingPathSegment(child, inheritedPending+thisSegment) - fc.SetPendingPropertyName(child, strconv.Itoa(int(i))) // For @parentProperty - } - result = append(result, child) - } - } - - return result - case selectorSubKindFilter: - var result []*yaml.Node - // Get parent property name - prefer pending property name from wildcard/slice, - // fall back to current PropertyName - var parentPropName string - var pushedPendingSegment bool - if fc, ok := idx.(FilterContext); ok { - // First check for pending property name from wildcard/slice - if pendingPropName := fc.GetAndClearPendingPropertyName(value); pendingPropName != "" { - parentPropName = pendingPropName - } else { - parentPropName = fc.PropertyName() - } - // Check if this node has a pending path segment from a wildcard/slice - if pendingSeg := fc.GetAndClearPendingPathSegment(value); pendingSeg != "" { - fc.PushPathSegment(pendingSeg) - pushedPendingSegment = true - } - } - switch value.Kind { - case yaml.MappingNode: - for i := 1; i < len(value.Content); i += 2 { - keyNode := value.Content[i-1] - valueNode := value.Content[i] - idx.setPropertyKey(keyNode, value) - idx.setPropertyKey(valueNode, keyNode) - if trackParents { - idx.setParentNode(valueNode, value) - } - - if fc, ok := idx.(FilterContext); ok { - fc.SetParentPropertyName(parentPropName) - fc.SetPropertyName(keyNode.Value) - fc.SetParent(value) - fc.SetIndex(-1) - fc.PushPathSegment(normalizePathSegment(keyNode.Value)) - } - - if s.filter.Matches(idx, valueNode, root) { - result = append(result, valueNode) - } - - if fc, ok := idx.(FilterContext); ok { - fc.PopPathSegment() - } - } - case yaml.SequenceNode: - for i, child := range value.Content { - if trackParents { - idx.setParentNode(child, value) - } - - if fc, ok := idx.(FilterContext); ok { - fc.SetParentPropertyName(parentPropName) - fc.SetPropertyName(strconv.Itoa(i)) - fc.SetParent(value) - fc.SetIndex(i) - fc.PushPathSegment(normalizeIndexSegment(i)) - } - - if s.filter.Matches(idx, child, root) { - result = append(result, child) - } - - if fc, ok := idx.(FilterContext); ok { - fc.PopPathSegment() - } - } - } - // Pop the pending segment if we pushed one - if pushedPendingSegment { - if fc, ok := idx.(FilterContext); ok { - fc.PopPathSegment() - } - } - return result - } - return nil + trackParents := parentTrackingEnabled(idx) + + switch s.kind { + case selectorSubKindName: + if value.Kind != yaml.MappingNode { + return nil + } + // Check for inherited pending segment from wildcard/slice + var inheritedPending string + if fc, ok := idx.(FilterContext); ok { + inheritedPending = fc.GetAndClearPendingPathSegment(value) + } + + var key string + for i, child := range value.Content { + if i%2 == 0 { + key = child.Value + continue + } + if key == s.name && i%2 == 1 { + idx.setPropertyKey(value.Content[i], value.Content[i-1]) + idx.setPropertyKey(value.Content[i-1], value) + if trackParents { + idx.setParentNode(child, value) + } + if fc, ok := idx.(FilterContext); ok { + thisSegment := normalizePathSegment(key) + if inheritedPending != "" { + // Propagate combined pending to result for later consumption + fc.SetPendingPathSegment(child, inheritedPending+thisSegment) + } else { + // No wildcard ancestry - push directly to path + fc.PushPathSegment(thisSegment) + } + fc.SetPropertyName(key) + } + return []*yaml.Node{child} + } + } + case selectorSubKindArrayIndex: + if value.Kind != yaml.SequenceNode { + return nil + } + if s.index >= int64(len(value.Content)) || s.index < -int64(len(value.Content)) { + return nil + } + // Check for inherited pending segment from wildcard/slice + var inheritedPending string + if fc, ok := idx.(FilterContext); ok { + inheritedPending = fc.GetAndClearPendingPathSegment(value) + } + + var child *yaml.Node + var actualIndex int + if s.index < 0 { + actualIndex = int(int64(len(value.Content)) + s.index) + child = value.Content[actualIndex] + } else { + actualIndex = int(s.index) + child = value.Content[s.index] + } + if trackParents { + idx.setParentNode(child, value) + } + if fc, ok := idx.(FilterContext); ok { + thisSegment := normalizeIndexSegment(actualIndex) + if inheritedPending != "" { + // Propagate combined pending to result for later consumption + fc.SetPendingPathSegment(child, inheritedPending+thisSegment) + } else { + // No wildcard ancestry - push directly to path + fc.PushPathSegment(thisSegment) + } + } + return []*yaml.Node{child} + case selectorSubKindWildcard: + // Check for inherited pending segment from previous wildcard/slice + var inheritedPending string + if fc, ok := idx.(FilterContext); ok { + inheritedPending = fc.GetAndClearPendingPathSegment(value) + } + + if value.Kind == yaml.SequenceNode { + for i, child := range value.Content { + if trackParents { + idx.setParentNode(child, value) + } + // Track pending path segment and property name for this node + if fc, ok := idx.(FilterContext); ok { + thisSegment := normalizeIndexSegment(i) + fc.SetPendingPathSegment(child, inheritedPending+thisSegment) + fc.SetPendingPropertyName(child, strconv.Itoa(i)) // For @parentProperty + } + } + return value.Content + } else if value.Kind == yaml.MappingNode { + var result []*yaml.Node + for i, child := range value.Content { + if i%2 == 1 { + keyNode := value.Content[i-1] + idx.setPropertyKey(keyNode, value) + idx.setPropertyKey(child, keyNode) + if trackParents { + idx.setParentNode(child, value) + } + // Track pending path segment and property name for this node + if fc, ok := idx.(FilterContext); ok { + thisSegment := normalizePathSegment(keyNode.Value) + fc.SetPendingPathSegment(child, inheritedPending+thisSegment) + fc.SetPendingPropertyName(child, keyNode.Value) // For @parentProperty + } + result = append(result, child) + } + } + return result + } + return nil + case selectorSubKindArraySlice: + if value.Kind != yaml.SequenceNode { + return nil + } + if len(value.Content) == 0 { + return nil + } + // Check for inherited pending segment from previous wildcard/slice + var inheritedPending string + if fc, ok := idx.(FilterContext); ok { + inheritedPending = fc.GetAndClearPendingPathSegment(value) + } + + step := int64(1) + if s.slice.step != nil { + step = *s.slice.step + } + if step == 0 { + return nil + } + + start, end := s.slice.start, s.slice.end + lower, upper := bounds(start, end, step, int64(len(value.Content))) + + var result []*yaml.Node + if step > 0 { + for i := lower; i < upper; i += step { + child := value.Content[i] + if trackParents { + idx.setParentNode(child, value) + } + // Track pending path segment and property name for this node + if fc, ok := idx.(FilterContext); ok { + thisSegment := normalizeIndexSegment(int(i)) + fc.SetPendingPathSegment(child, inheritedPending+thisSegment) + fc.SetPendingPropertyName(child, strconv.Itoa(int(i))) // For @parentProperty + } + result = append(result, child) + } + } else { + for i := upper; i > lower; i += step { + child := value.Content[i] + if trackParents { + idx.setParentNode(child, value) + } + // Track pending path segment and property name for this node + if fc, ok := idx.(FilterContext); ok { + thisSegment := normalizeIndexSegment(int(i)) + fc.SetPendingPathSegment(child, inheritedPending+thisSegment) + fc.SetPendingPropertyName(child, strconv.Itoa(int(i))) // For @parentProperty + } + result = append(result, child) + } + } + + return result + case selectorSubKindFilter: + var result []*yaml.Node + // Get parent property name - prefer pending property name from wildcard/slice, + // fall back to current PropertyName + var parentPropName string + var pushedPendingSegment bool + if fc, ok := idx.(FilterContext); ok { + // First check for pending property name from wildcard/slice + if pendingPropName := fc.GetAndClearPendingPropertyName(value); pendingPropName != "" { + parentPropName = pendingPropName + } else { + parentPropName = fc.PropertyName() + } + // Check if this node has a pending path segment from a wildcard/slice + if pendingSeg := fc.GetAndClearPendingPathSegment(value); pendingSeg != "" { + fc.PushPathSegment(pendingSeg) + pushedPendingSegment = true + } + } + switch value.Kind { + case yaml.MappingNode: + for i := 1; i < len(value.Content); i += 2 { + keyNode := value.Content[i-1] + valueNode := value.Content[i] + idx.setPropertyKey(keyNode, value) + idx.setPropertyKey(valueNode, keyNode) + if trackParents { + idx.setParentNode(valueNode, value) + } + + if fc, ok := idx.(FilterContext); ok { + fc.SetParentPropertyName(parentPropName) + fc.SetPropertyName(keyNode.Value) + fc.SetParent(value) + fc.SetIndex(-1) + fc.PushPathSegment(normalizePathSegment(keyNode.Value)) + } + + if s.filter.Matches(idx, valueNode, root) { + result = append(result, valueNode) + } + + if fc, ok := idx.(FilterContext); ok { + fc.PopPathSegment() + } + } + case yaml.SequenceNode: + for i, child := range value.Content { + if trackParents { + idx.setParentNode(child, value) + } + + if fc, ok := idx.(FilterContext); ok { + fc.SetParentPropertyName(parentPropName) + fc.SetPropertyName(strconv.Itoa(i)) + fc.SetParent(value) + fc.SetIndex(i) + fc.PushPathSegment(normalizeIndexSegment(i)) + } + + if s.filter.Matches(idx, child, root) { + result = append(result, child) + } + + if fc, ok := idx.(FilterContext); ok { + fc.PopPathSegment() + } + } + } + // Pop the pending segment if we pushed one + if pushedPendingSegment { + if fc, ok := idx.(FilterContext); ok { + fc.PopPathSegment() + } + } + return result + } + return nil } func normalize(i, length int64) int64 { - if i >= 0 { - return i - } - return length + i + if i >= 0 { + return i + } + return length + i } func bounds(start, end *int64, step, length int64) (int64, int64) { - var nStart, nEnd int64 - if start != nil { - nStart = normalize(*start, length) - } else if step > 0 { - nStart = 0 - } else { - nStart = length - 1 - } - if end != nil { - nEnd = normalize(*end, length) - } else if step > 0 { - nEnd = length - } else { - nEnd = -1 - } - - var lower, upper int64 - if step >= 0 { - lower = max(min(nStart, length), 0) - upper = min(max(nEnd, 0), length) - } else { - upper = min(max(nStart, -1), length-1) - lower = min(max(nEnd, -1), length-1) - } - - return lower, upper + var nStart, nEnd int64 + if start != nil { + nStart = normalize(*start, length) + } else if step > 0 { + nStart = 0 + } else { + nStart = length - 1 + } + if end != nil { + nEnd = normalize(*end, length) + } else if step > 0 { + nEnd = length + } else { + nEnd = -1 + } + + var lower, upper int64 + if step >= 0 { + lower = max(min(nStart, length), 0) + upper = min(max(nEnd, 0), length) + } else { + upper = min(max(nStart, -1), length-1) + lower = min(max(nEnd, -1), length-1) + } + + return lower, upper } func (s filterSelector) Matches(idx index, node *yaml.Node, root *yaml.Node) bool { - return s.expression.Matches(idx, node, root) + return s.expression.Matches(idx, node, root) } func (e logicalOrExpr) Matches(idx index, node *yaml.Node, root *yaml.Node) bool { - for _, expr := range e.expressions { - if expr.Matches(idx, node, root) { - return true - } - } - return false + for _, expr := range e.expressions { + if expr.Matches(idx, node, root) { + return true + } + } + return false } func (e logicalAndExpr) Matches(idx index, node *yaml.Node, root *yaml.Node) bool { - for _, expr := range e.expressions { - if !expr.Matches(idx, node, root) { - return false - } - } - return true + for _, expr := range e.expressions { + if !expr.Matches(idx, node, root) { + return false + } + } + return true } func (e basicExpr) Matches(idx index, node *yaml.Node, root *yaml.Node) bool { - if e.parenExpr != nil { - result := e.parenExpr.expr.Matches(idx, node, root) - if e.parenExpr.not { - return !result - } - return result - } else if e.comparisonExpr != nil { - return e.comparisonExpr.Matches(idx, node, root) - } else if e.testExpr != nil { - return e.testExpr.Matches(idx, node, root) - } - return false + if e.parenExpr != nil { + result := e.parenExpr.expr.Matches(idx, node, root) + if e.parenExpr.not { + return !result + } + return result + } else if e.comparisonExpr != nil { + return e.comparisonExpr.Matches(idx, node, root) + } else if e.testExpr != nil { + return e.testExpr.Matches(idx, node, root) + } + return false } func (e comparisonExpr) Matches(idx index, node *yaml.Node, root *yaml.Node) bool { - leftValue := e.left.Evaluate(idx, node, root) - rightValue := e.right.Evaluate(idx, node, root) - - switch e.op { - case equalTo: - return leftValue.Equals(rightValue) - case notEqualTo: - return !leftValue.Equals(rightValue) - case lessThan: - return leftValue.LessThan(rightValue) - case lessThanEqualTo: - return leftValue.LessThanOrEqual(rightValue) - case greaterThan: - return rightValue.LessThan(leftValue) - case greaterThanEqualTo: - return rightValue.LessThanOrEqual(leftValue) - default: - return false - } + leftValue := e.left.Evaluate(idx, node, root) + rightValue := e.right.Evaluate(idx, node, root) + + switch e.op { + case equalTo: + return leftValue.Equals(rightValue) + case notEqualTo: + return !leftValue.Equals(rightValue) + case lessThan: + return leftValue.LessThan(rightValue) + case lessThanEqualTo: + return leftValue.LessThanOrEqual(rightValue) + case greaterThan: + return rightValue.LessThan(leftValue) + case greaterThanEqualTo: + return rightValue.LessThanOrEqual(leftValue) + default: + return false + } } func (e testExpr) Matches(idx index, node *yaml.Node, root *yaml.Node) bool { - var result bool - if e.filterQuery != nil { - result = len(e.filterQuery.Query(idx, node, root)) > 0 - } else if e.functionExpr != nil { - funcResult := e.functionExpr.Evaluate(idx, node, root) - if funcResult.bool != nil { - result = *funcResult.bool - } else if funcResult.null == nil { - result = true - } - } - if e.not { - return !result - } - return result + var result bool + if e.filterQuery != nil { + result = len(e.filterQuery.Query(idx, node, root)) > 0 + } else if e.functionExpr != nil { + funcResult := e.functionExpr.Evaluate(idx, node, root) + if funcResult.bool != nil { + result = *funcResult.bool + } else if funcResult.null == nil { + result = true + } + } + if e.not { + return !result + } + return result } func (q filterQuery) Query(idx index, node *yaml.Node, root *yaml.Node) []*yaml.Node { - if q.relQuery != nil { - return q.relQuery.Query(idx, node, root) - } - if q.jsonPathQuery != nil { - return q.jsonPathQuery.Query(node, root) - } - return nil + if q.relQuery != nil { + return q.relQuery.Query(idx, node, root) + } + if q.jsonPathQuery != nil { + return q.jsonPathQuery.Query(node, root) + } + return nil } func (q relQuery) Query(idx index, node *yaml.Node, root *yaml.Node) []*yaml.Node { - result := []*yaml.Node{node} - for _, seg := range q.segments { - var newResult []*yaml.Node - for _, value := range result { - newResult = append(newResult, seg.Query(idx, value, root)...) - } - result = newResult - } - return result + result := []*yaml.Node{node} + for _, seg := range q.segments { + var newResult []*yaml.Node + for _, value := range result { + newResult = append(newResult, seg.Query(idx, value, root)...) + } + result = newResult + } + return result } func (q absQuery) Query(idx index, node *yaml.Node, root *yaml.Node) []*yaml.Node { - result := []*yaml.Node{root} - for _, seg := range q.segments { - var newResult []*yaml.Node - for _, value := range result { - newResult = append(newResult, seg.Query(idx, value, root)...) - } - result = newResult - } - return result + result := []*yaml.Node{root} + for _, seg := range q.segments { + var newResult []*yaml.Node + for _, value := range result { + newResult = append(newResult, seg.Query(idx, value, root)...) + } + result = newResult + } + return result } diff --git a/pkg/overlay/apply.go b/pkg/overlay/apply.go index c191ee8..0fb7c96 100644 --- a/pkg/overlay/apply.go +++ b/pkg/overlay/apply.go @@ -1,169 +1,315 @@ package overlay import ( - "github.com/pb33f/jsonpath/pkg/jsonpath" - "github.com/pb33f/jsonpath/pkg/jsonpath/config" - "go.yaml.in/yaml/v4" + "fmt" + + "github.com/pb33f/jsonpath/pkg/jsonpath" + "github.com/pb33f/jsonpath/pkg/jsonpath/config" + "go.yaml.in/yaml/v4" ) // ApplyTo will take an overlay and apply its changes to the given YAML // document. func (o *Overlay) ApplyTo(root *yaml.Node) error { - for _, action := range o.Actions { - var err error - if action.Remove { - err = applyRemoveAction(root, action) - } else { - err = applyUpdateAction(root, action) - } + for _, action := range o.Actions { + var err error + if action.Remove { + err = applyRemoveAction(root, action) + } else { + err = applyUpdateAction(root, action) + } - if err != nil { - return err - } - } + if err != nil { + return err + } + } - return nil + return nil } func applyRemoveAction(root *yaml.Node, action Action) error { - if action.Target == "" { - return nil - } + if action.Target == "" { + return nil + } - idx := newParentIndex(root) + idx := newParentIndex(root) - p, err := jsonpath.NewPath(action.Target, config.WithPropertyNameExtension()) - if err != nil { - return err - } + p, err := jsonpath.NewPath(action.Target, config.WithPropertyNameExtension()) + if err != nil { + return err + } - nodes := p.Query(root) - if err != nil { - return err - } + nodes := p.Query(root) + if err != nil { + return err + } - for _, node := range nodes { - removeNode(idx, node) - } + for _, node := range nodes { + removeNode(idx, node) + } - return nil + return nil } func removeNode(idx parentIndex, node *yaml.Node) { - parent := idx.getParent(node) - if parent == nil { - return - } - - for i, child := range parent.Content { - if child == node { - switch parent.Kind { - case yaml.MappingNode: - if i%2 == 1 { - // if we select a value, we should delete the key too - parent.Content = append(parent.Content[:i-1], parent.Content[i+1:]...) - } else { - // if we select a key, we should delete the value - parent.Content = append(parent.Content[:i], parent.Content[i+2:]...) - } - return - case yaml.SequenceNode: - parent.Content = append(parent.Content[:i], parent.Content[i+1:]...) - return - } - } - } + parent := idx.getParent(node) + if parent == nil { + return + } + + for i, child := range parent.Content { + if child == node { + switch parent.Kind { + case yaml.MappingNode: + if i%2 == 1 { + // if we select a value, we should delete the key too + parent.Content = append(parent.Content[:i-1], parent.Content[i+1:]...) + } else { + // if we select a key, we should delete the value + parent.Content = append(parent.Content[:i], parent.Content[i+2:]...) + } + return + case yaml.SequenceNode: + parent.Content = append(parent.Content[:i], parent.Content[i+1:]...) + return + } + } + } } func applyUpdateAction(root *yaml.Node, action Action) error { - if action.Target == "" { - return nil - } + if action.Target == "" { + return nil + } + + if action.Update.IsZero() { + return nil + } - if action.Update.IsZero() { - return nil - } + p, err := jsonpath.NewPath(action.Target, config.WithPropertyNameExtension()) + if err != nil { + return err + } - p, err := jsonpath.NewPath(action.Target, config.WithPropertyNameExtension()) - if err != nil { - return err - } + nodes := p.Query(root) - nodes := p.Query(root) + if len(nodes) == 0 && action.Upsert { + return createPath(root, p, &action.Update) + } - for _, node := range nodes { - if err := updateNode(node, &action.Update); err != nil { - return err - } - } + for _, node := range nodes { + if err := updateNode(node, &action.Update); err != nil { + return err + } + } - return nil + return nil } func updateNode(node *yaml.Node, updateNode *yaml.Node) error { - mergeNode(node, updateNode) - return nil + mergeNode(node, updateNode) + return nil } func mergeNode(node *yaml.Node, merge *yaml.Node) { - if node.Kind != merge.Kind { - *node = *clone(merge) - return - } - switch node.Kind { - default: - node.Value = merge.Value - case yaml.MappingNode: - mergeMappingNode(node, merge) - case yaml.SequenceNode: - mergeSequenceNode(node, merge) - } + if node.Kind != merge.Kind { + *node = *clone(merge) + return + } + switch node.Kind { + default: + node.Value = merge.Value + case yaml.MappingNode: + mergeMappingNode(node, merge) + case yaml.SequenceNode: + mergeSequenceNode(node, merge) + } } // mergeMappingNode will perform a shallow merge of the merge node into the main // node. func mergeMappingNode(node *yaml.Node, merge *yaml.Node) { NextKey: - for i := 0; i < len(merge.Content); i += 2 { - mergeKey := merge.Content[i].Value - mergeValue := merge.Content[i+1] + for i := 0; i < len(merge.Content); i += 2 { + mergeKey := merge.Content[i].Value + mergeValue := merge.Content[i+1] - for j := 0; j < len(node.Content); j += 2 { - nodeKey := node.Content[j].Value - if nodeKey == mergeKey { - mergeNode(node.Content[j+1], mergeValue) - continue NextKey - } - } + for j := 0; j < len(node.Content); j += 2 { + nodeKey := node.Content[j].Value + if nodeKey == mergeKey { + mergeNode(node.Content[j+1], mergeValue) + continue NextKey + } + } - node.Content = append(node.Content, merge.Content[i], clone(mergeValue)) - } + node.Content = append(node.Content, merge.Content[i], clone(mergeValue)) + } } // mergeSequenceNode will append the merge node's content to the original node. func mergeSequenceNode(node *yaml.Node, merge *yaml.Node) { - node.Content = append(node.Content, clone(merge).Content...) + node.Content = append(node.Content, clone(merge).Content...) } func clone(node *yaml.Node) *yaml.Node { - newNode := &yaml.Node{ - Kind: node.Kind, - Style: node.Style, - Tag: node.Tag, - Value: node.Value, - Anchor: node.Anchor, - HeadComment: node.HeadComment, - LineComment: node.LineComment, - FootComment: node.FootComment, - } - if node.Alias != nil { - newNode.Alias = clone(node.Alias) - } - if node.Content != nil { - newNode.Content = make([]*yaml.Node, len(node.Content)) - for i, child := range node.Content { - newNode.Content[i] = clone(child) - } - } - return newNode + newNode := &yaml.Node{ + Kind: node.Kind, + Style: node.Style, + Tag: node.Tag, + Value: node.Value, + Anchor: node.Anchor, + HeadComment: node.HeadComment, + LineComment: node.LineComment, + FootComment: node.FootComment, + } + if node.Alias != nil { + newNode.Alias = clone(node.Alias) + } + if node.Content != nil { + newNode.Content = make([]*yaml.Node, len(node.Content)) + for i, child := range node.Content { + newNode.Content[i] = clone(child) + } + } + return newNode +} + +func createPath(root *yaml.Node, p *jsonpath.JSONPath, value *yaml.Node) error { + segments, err := p.GetSegmentInfo() + if err != nil { + return err + } + + if len(segments) == 0 { + return fmt.Errorf("empty path for upsert") + } + + current := root + if root.Kind == yaml.DocumentNode && len(root.Content) == 1 { + current = root.Content[0] + } + + for i, seg := range segments[:len(segments)-1] { + next, err := ensureSegment(current, seg, segments[i+1]) + if err != nil { + return err + } + current = next + } + + lastSeg := segments[len(segments)-1] + return setFinalValue(current, lastSeg, value) +} + +func ensureSegment(node *yaml.Node, seg, nextSeg jsonpath.SegmentInfo) (*yaml.Node, error) { + switch seg.Kind { + case jsonpath.SegmentKindMemberName: + return ensureMapKey(node, seg.Key, nextSeg) + case jsonpath.SegmentKindArrayIndex: + return ensureArrayIndex(node, seg.Index) + default: + return nil, fmt.Errorf("unknown segment kind: %v", seg.Kind) + } +} + +func ensureMapKey(node *yaml.Node, key string, nextSeg jsonpath.SegmentInfo) (*yaml.Node, error) { + if node.Kind != yaml.MappingNode { + return nil, fmt.Errorf("expected mapping node, got %v", node.Kind) + } + + for i := 0; i < len(node.Content); i += 2 { + if node.Content[i].Value == key { + return node.Content[i+1], nil + } + } + + var newKind yaml.Kind + if nextSeg.Kind == jsonpath.SegmentKindArrayIndex { + newKind = yaml.SequenceNode + } else { + newKind = yaml.MappingNode + } + + newNode := &yaml.Node{ + Kind: newKind, + Tag: "!!map", + Content: []*yaml.Node{}, + } + if newKind == yaml.SequenceNode { + newNode.Tag = "!!seq" + } + + keyNode := &yaml.Node{ + Kind: yaml.ScalarNode, + Tag: "!!str", + Value: key, + } + node.Content = append(node.Content, keyNode, newNode) + + return newNode, nil +} + +func ensureArrayIndex(node *yaml.Node, index int64) (*yaml.Node, error) { + if node.Kind != yaml.SequenceNode { + return nil, fmt.Errorf("expected sequence node, got %v", node.Kind) + } + + if index < 0 { + return nil, fmt.Errorf("negative array index not supported for upsert: %d", index) + } + + if index >= int64(len(node.Content)) { + return nil, fmt.Errorf("array index out of bounds for upsert: %d (array length: %d)", index, len(node.Content)) + } + + return node.Content[index], nil +} + +func setFinalValue(node *yaml.Node, seg jsonpath.SegmentInfo, value *yaml.Node) error { + switch seg.Kind { + case jsonpath.SegmentKindMemberName: + return setMapValue(node, seg.Key, value) + case jsonpath.SegmentKindArrayIndex: + return setArrayValue(node, seg.Index, value) + default: + return fmt.Errorf("unknown segment kind: %v", seg.Kind) + } +} + +func setMapValue(node *yaml.Node, key string, value *yaml.Node) error { + if node.Kind != yaml.MappingNode { + return fmt.Errorf("expected mapping node, got %v", node.Kind) + } + + for i := 0; i < len(node.Content); i += 2 { + if node.Content[i].Value == key { + mergeNode(node.Content[i+1], value) + return nil + } + } + + keyNode := &yaml.Node{ + Kind: yaml.ScalarNode, + Tag: "!!str", + Value: key, + } + node.Content = append(node.Content, keyNode, clone(value)) + return nil +} + +func setArrayValue(node *yaml.Node, index int64, value *yaml.Node) error { + if node.Kind != yaml.SequenceNode { + return fmt.Errorf("expected sequence node, got %v", node.Kind) + } + + if index < 0 { + return fmt.Errorf("negative array index not supported for upsert: %d", index) + } + + if index >= int64(len(node.Content)) { + return fmt.Errorf("array index out of bounds for upsert: %d (array length: %d)", index, len(node.Content)) + } + + mergeNode(node.Content[index], value) + return nil } diff --git a/pkg/overlay/apply_test.go b/pkg/overlay/apply_test.go index 8605f26..4a8651b 100644 --- a/pkg/overlay/apply_test.go +++ b/pkg/overlay/apply_test.go @@ -1,58 +1,246 @@ package overlay_test import ( - "bytes" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.yaml.in/yaml/v4" - "os" - "strings" - "testing" + "bytes" + "testing" + + "github.com/pb33f/jsonpath/pkg/overlay" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v4" + "os" + "strings" ) // NodeMatchesFile is a test that marshals the YAML file from the given node, // then compares those bytes to those found in the expected file. func NodeMatchesFile( - t *testing.T, - actual *yaml.Node, - expectedFile string, - msgAndArgs ...any, + t *testing.T, + actual *yaml.Node, + expectedFile string, + msgAndArgs ...any, ) { - variadoc := func(pre ...any) []any { return append(msgAndArgs, pre...) } + variadoc := func(pre ...any) []any { return append(msgAndArgs, pre...) } - var actualBuf bytes.Buffer - enc := yaml.NewEncoder(&actualBuf) - enc.SetIndent(2) - err := enc.Encode(actual) - require.NoError(t, err, variadoc("failed to marshal node: ")...) + var actualBuf bytes.Buffer + enc := yaml.NewEncoder(&actualBuf) + enc.SetIndent(2) + err := enc.Encode(actual) + require.NoError(t, err, variadoc("failed to marshal node: ")...) - expectedBytes, err := os.ReadFile(expectedFile) - require.NoError(t, err, variadoc("failed to read expected file: ")...) + expectedBytes, err := os.ReadFile(expectedFile) + require.NoError(t, err, variadoc("failed to read expected file: ")...) - // lazy redo snapshot - //os.WriteFile(expectedFile, actualBuf.Bytes(), 0644) + // lazy redo snapshot + //os.WriteFile(expectedFile, actualBuf.Bytes(), 0644) - //t.Log("### EXPECT START ###\n" + string(expectedBytes) + "\n### EXPECT END ###\n") - //t.Log("### ACTUAL START ###\n" + actualBuf.string() + "\n### ACTUAL END ###\n") + //t.Log("### EXPECT START ###\n" + string(expectedBytes) + "\n### EXPECT END ###\n") + //t.Log("### ACTUAL START ###\n" + actualBuf.string() + "\n### ACTUAL END ###\n") - // Normalize line endings for cross-platform compatibility (Windows CRLF vs Unix LF) - expectedStr := strings.ReplaceAll(string(expectedBytes), "\r\n", "\n") - actualStr := strings.ReplaceAll(actualBuf.String(), "\r\n", "\n") + // Normalize line endings for cross-platform compatibility (Windows CRLF vs Unix LF) + expectedStr := strings.ReplaceAll(string(expectedBytes), "\r\n", "\n") + actualStr := strings.ReplaceAll(actualBuf.String(), "\r\n", "\n") - assert.Equal(t, expectedStr, actualStr, variadoc("node does not match expected file: ")...) + assert.Equal(t, expectedStr, actualStr, variadoc("node does not match expected file: ")...) } func TestApplyTo(t *testing.T) { - t.Parallel() + t.Parallel() + + node, err := LoadSpecification("testdata/openapi.yaml") + require.NoError(t, err) + + o, err := LoadOverlay("testdata/overlay.yaml") + require.NoError(t, err) + + err = o.ApplyTo(node) + assert.NoError(t, err) + + NodeMatchesFile(t, node, "testdata/openapi-overlayed.yaml") +} + +func TestUpsertCreateNested(t *testing.T) { + node, err := LoadSpecification("testdata/upsert/create-nested-input.yaml") + require.NoError(t, err) + + o, err := LoadOverlay("testdata/upsert/create-nested-overlay.yaml") + require.NoError(t, err) + + err = o.Validate() + require.NoError(t, err) + + err = o.ApplyTo(node) + require.NoError(t, err) + + NodeMatchesFile(t, node, "testdata/upsert/create-nested-expected.yaml") +} + +func TestUpsertUpdateExisting(t *testing.T) { + node, err := LoadSpecification("testdata/upsert/update-existing-input.yaml") + require.NoError(t, err) + + o, err := LoadOverlay("testdata/upsert/update-existing-overlay.yaml") + require.NoError(t, err) + + err = o.Validate() + require.NoError(t, err) + + err = o.ApplyTo(node) + require.NoError(t, err) + + NodeMatchesFile(t, node, "testdata/upsert/update-existing-expected.yaml") +} + +func TestUpsertMultipleLevels(t *testing.T) { + node, err := LoadSpecification("testdata/upsert/multiple-levels-input.yaml") + require.NoError(t, err) + + o, err := LoadOverlay("testdata/upsert/multiple-levels-overlay.yaml") + require.NoError(t, err) + + err = o.Validate() + require.NoError(t, err) + + err = o.ApplyTo(node) + require.NoError(t, err) + + NodeMatchesFile(t, node, "testdata/upsert/multiple-levels-expected.yaml") +} + +func TestUpsertArrayElement(t *testing.T) { + node, err := LoadSpecification("testdata/upsert/array-element-input.yaml") + require.NoError(t, err) + + o, err := LoadOverlay("testdata/upsert/array-element-overlay.yaml") + require.NoError(t, err) + + err = o.Validate() + require.NoError(t, err) + + err = o.ApplyTo(node) + require.NoError(t, err) + + NodeMatchesFile(t, node, "testdata/upsert/array-element-expected.yaml") +} + +func TestUpsertRejectWildcard(t *testing.T) { + o, err := LoadOverlay("testdata/upsert/reject-wildcard-overlay.yaml") + require.NoError(t, err) + + err = o.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "upsert requires a singular path") +} + +func TestUpsertRejectRecursive(t *testing.T) { + o, err := LoadOverlay("testdata/upsert/reject-recursive-overlay.yaml") + require.NoError(t, err) + + err = o.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "upsert requires a singular path") +} + +func TestUpsertRejectFilter(t *testing.T) { + o, err := LoadOverlay("testdata/upsert/reject-filter-overlay.yaml") + require.NoError(t, err) + + err = o.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "upsert requires a singular path") +} + +func TestUpsertRejectUppsertWithRemove(t *testing.T) { + o, err := LoadOverlay("testdata/upsert/reject-upsert-remove-overlay.yaml") + require.NoError(t, err) + + err = o.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "should not both set upsert and remove") +} + +func TestUpsertArrayOutOfBounds(t *testing.T) { + node, err := LoadSpecification("testdata/upsert/array-element-input.yaml") + require.NoError(t, err) + + var ovl overlay.Overlay + data := []byte(` +overlay: "1.0.0" +info: + title: test + version: "1.0" +actions: + - target: $.items[5].name + update: value + upsert: true +`) + err = yaml.Unmarshal(data, &ovl) + require.NoError(t, err) + + err = ovl.Validate() + require.NoError(t, err) + + err = ovl.ApplyTo(node) + assert.Error(t, err) + assert.Contains(t, err.Error(), "array index out of bounds") +} + +func TestUpsertTypeMismatch(t *testing.T) { + node, err := LoadSpecification("testdata/upsert/array-element-input.yaml") + require.NoError(t, err) + + var ovl overlay.Overlay + data := []byte(` +overlay: "1.0.0" +info: + title: test + version: "1.0" +actions: + - target: $.items[0].name.extra + update: value + upsert: true +`) + err = yaml.Unmarshal(data, &ovl) + require.NoError(t, err) + + err = ovl.Validate() + require.NoError(t, err) + + err = ovl.ApplyTo(node) + assert.Error(t, err) + assert.Contains(t, err.Error(), "expected mapping node") +} + +func TestUpsertBracketNotation(t *testing.T) { + node, err := LoadSpecification("testdata/upsert/multiple-levels-input.yaml") + require.NoError(t, err) + + var ovl overlay.Overlay + data := []byte(` +overlay: "1.0.0" +info: + title: test + version: "1.0" +actions: + - target: $['spec']['config']['key'] + update: value + upsert: true +`) + err = yaml.Unmarshal(data, &ovl) + require.NoError(t, err) - node, err := LoadSpecification("testdata/openapi.yaml") - require.NoError(t, err) + err = ovl.Validate() + require.NoError(t, err) - o, err := LoadOverlay("testdata/overlay.yaml") - require.NoError(t, err) + err = ovl.ApplyTo(node) + require.NoError(t, err) - err = o.ApplyTo(node) - assert.NoError(t, err) + var actualBuf bytes.Buffer + enc := yaml.NewEncoder(&actualBuf) + enc.SetIndent(2) + err = enc.Encode(node) + require.NoError(t, err) - NodeMatchesFile(t, node, "testdata/openapi-overlayed.yaml") + assert.Contains(t, actualBuf.String(), "config:") + assert.Contains(t, actualBuf.String(), "key: value") } diff --git a/pkg/overlay/parse.go b/pkg/overlay/parse.go index 25a810e..2c225d5 100644 --- a/pkg/overlay/parse.go +++ b/pkg/overlay/parse.go @@ -1,58 +1,58 @@ package overlay import ( - "fmt" - "go.yaml.in/yaml/v4" - "io" - "os" - "path/filepath" + "fmt" + "go.yaml.in/yaml/v4" + "io" + "os" + "path/filepath" ) // Parse will parse the given reader as an overlay file. func Parse(path string) (*Overlay, error) { - filePath, err := filepath.Abs(path) - if err != nil { - return nil, fmt.Errorf("failed to get absolute path for %q: %w", path, err) - } - - ro, err := os.Open(filePath) - if err != nil { - return nil, fmt.Errorf("failed to open overlay file at path %q: %w", path, err) - } - defer ro.Close() - - var overlay Overlay - dec := yaml.NewDecoder(ro) - - err = dec.Decode(&overlay) - if err != nil { - return nil, err - } - - return &overlay, err + filePath, err := filepath.Abs(path) + if err != nil { + return nil, fmt.Errorf("failed to get absolute path for %q: %w", path, err) + } + + ro, err := os.Open(filePath) + if err != nil { + return nil, fmt.Errorf("failed to open overlay file at path %q: %w", path, err) + } + defer ro.Close() + + var overlay Overlay + dec := yaml.NewDecoder(ro) + + err = dec.Decode(&overlay) + if err != nil { + return nil, err + } + + return &overlay, err } // Format will validate reformat the given file func Format(path string) error { - overlay, err := Parse(path) - if err != nil { - return err - } - filePath, err := filepath.Abs(path) - if err != nil { - return fmt.Errorf("failed to open overlay file at path %q: %w", path, err) - } - formatted, err := overlay.ToString() - if err != nil { - return err - } - - return os.WriteFile(filePath, []byte(formatted), 0644) + overlay, err := Parse(path) + if err != nil { + return err + } + filePath, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("failed to open overlay file at path %q: %w", path, err) + } + formatted, err := overlay.ToString() + if err != nil { + return err + } + + return os.WriteFile(filePath, []byte(formatted), 0644) } // Format writes the file back out as YAML. func (o *Overlay) Format(w io.Writer) error { - enc := yaml.NewEncoder(w) - enc.SetIndent(2) - return enc.Encode(o) + enc := yaml.NewEncoder(w) + enc.SetIndent(2) + return enc.Encode(o) } diff --git a/pkg/overlay/schema.go b/pkg/overlay/schema.go index 97ca5d4..8f1c476 100644 --- a/pkg/overlay/schema.go +++ b/pkg/overlay/schema.go @@ -1,8 +1,8 @@ package overlay import ( - "bytes" - "go.yaml.in/yaml/v4" + "bytes" + "go.yaml.in/yaml/v4" ) // Extensible provides a place for extensions to be added to components of the @@ -11,56 +11,60 @@ type Extensions map[string]any // Overlay is the top-level configuration for an OpenAPI overlay. type Overlay struct { - Extensions Extensions `yaml:",inline"` + Extensions Extensions `yaml:",inline"` - // Version is the version of the overlay configuration. - Version string `yaml:"overlay"` + // Version is the version of the overlay configuration. + Version string `yaml:"overlay"` - // JSONPathVersion should be set to rfc9535, and is used for backwards compatability purposes - JSONPathVersion string `yaml:"x-speakeasy-jsonpath,omitempty"` + // JSONPathVersion should be set to rfc9535, and is used for backwards compatability purposes + JSONPathVersion string `yaml:"x-speakeasy-jsonpath,omitempty"` - // Info describes the metadata for the overlay. - Info Info `yaml:"info"` + // Info describes the metadata for the overlay. + Info Info `yaml:"info"` - // Extends is a URL to the OpenAPI specification this overlay applies to. - Extends string `yaml:"extends,omitempty"` + // Extends is a URL to the OpenAPI specification this overlay applies to. + Extends string `yaml:"extends,omitempty"` - // Actions is the list of actions to perform to apply the overlay. - Actions []Action `yaml:"actions"` + // Actions is the list of actions to perform to apply the overlay. + Actions []Action `yaml:"actions"` } func (o *Overlay) ToString() (string, error) { - buf := bytes.NewBuffer([]byte{}) - decoder := yaml.NewEncoder(buf) - decoder.SetIndent(2) - err := decoder.Encode(o) - return buf.String(), err + buf := bytes.NewBuffer([]byte{}) + decoder := yaml.NewEncoder(buf) + decoder.SetIndent(2) + err := decoder.Encode(o) + return buf.String(), err } // Info describes the metadata for the overlay. type Info struct { - Extensions `yaml:"-,inline"` + Extensions `yaml:"-,inline"` - // Title is the title of the overlay. - Title string `yaml:"title"` + // Title is the title of the overlay. + Title string `yaml:"title"` - // Version is the version of the overlay. - Version string `yaml:"version"` + // Version is the version of the overlay. + Version string `yaml:"version"` } type Action struct { - Extensions `yaml:"-,inline"` + Extensions `yaml:"-,inline"` - // Target is the JSONPath to the target of the action. - Target string `yaml:"target"` + // Target is the JSONPath to the target of the action. + Target string `yaml:"target"` - // Description is a description of the action. - Description string `yaml:"description,omitempty"` + // Description is a description of the action. + Description string `yaml:"description,omitempty"` - // Update is the sub-document to use to merge or replace in the target. This is - // ignored if Remove is set. - Update yaml.Node `yaml:"update,omitempty"` + // Update is the sub-document to use to merge or replace in the target. This is + // ignored if Remove is set. + Update yaml.Node `yaml:"update,omitempty"` - // Remove marks the target node for removal rather than update. - Remove bool `yaml:"remove,omitempty"` + // Remove marks the target node for removal rather than update. + Remove bool `yaml:"remove,omitempty"` + + // Upsert creates the path if it doesn't exist, or updates if it does. + // Only works with singular paths (no wildcards, recursive descent, or filters). + Upsert bool `yaml:"upsert,omitempty"` } diff --git a/pkg/overlay/testdata/upsert/array-element-expected.yaml b/pkg/overlay/testdata/upsert/array-element-expected.yaml new file mode 100644 index 0000000..bc1f26b --- /dev/null +++ b/pkg/overlay/testdata/upsert/array-element-expected.yaml @@ -0,0 +1,2 @@ +items: + - name: updated diff --git a/pkg/overlay/testdata/upsert/array-element-input.yaml b/pkg/overlay/testdata/upsert/array-element-input.yaml new file mode 100644 index 0000000..6c0c00f --- /dev/null +++ b/pkg/overlay/testdata/upsert/array-element-input.yaml @@ -0,0 +1,2 @@ +items: + - name: first diff --git a/pkg/overlay/testdata/upsert/array-element-overlay.yaml b/pkg/overlay/testdata/upsert/array-element-overlay.yaml new file mode 100644 index 0000000..6841b43 --- /dev/null +++ b/pkg/overlay/testdata/upsert/array-element-overlay.yaml @@ -0,0 +1,8 @@ +overlay: "1.0.0" +info: + title: test + version: "1.0" +actions: + - target: $.items[0].name + update: updated + upsert: true diff --git a/pkg/overlay/testdata/upsert/create-nested-expected.yaml b/pkg/overlay/testdata/upsert/create-nested-expected.yaml new file mode 100644 index 0000000..7ff45ac --- /dev/null +++ b/pkg/overlay/testdata/upsert/create-nested-expected.yaml @@ -0,0 +1,5 @@ +spec: + existing: value + config: + nested: + key: created diff --git a/pkg/overlay/testdata/upsert/create-nested-input.yaml b/pkg/overlay/testdata/upsert/create-nested-input.yaml new file mode 100644 index 0000000..e8bfc1c --- /dev/null +++ b/pkg/overlay/testdata/upsert/create-nested-input.yaml @@ -0,0 +1,2 @@ +spec: + existing: value diff --git a/pkg/overlay/testdata/upsert/create-nested-overlay.yaml b/pkg/overlay/testdata/upsert/create-nested-overlay.yaml new file mode 100644 index 0000000..2055286 --- /dev/null +++ b/pkg/overlay/testdata/upsert/create-nested-overlay.yaml @@ -0,0 +1,8 @@ +overlay: "1.0.0" +info: + title: test + version: "1.0" +actions: + - target: $.spec.config.nested.key + update: created + upsert: true diff --git a/pkg/overlay/testdata/upsert/multiple-levels-expected.yaml b/pkg/overlay/testdata/upsert/multiple-levels-expected.yaml new file mode 100644 index 0000000..54ea3b3 --- /dev/null +++ b/pkg/overlay/testdata/upsert/multiple-levels-expected.yaml @@ -0,0 +1 @@ +{a: {b: {c: {d: {e: deep}}}}} diff --git a/pkg/overlay/testdata/upsert/multiple-levels-input.yaml b/pkg/overlay/testdata/upsert/multiple-levels-input.yaml new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/pkg/overlay/testdata/upsert/multiple-levels-input.yaml @@ -0,0 +1 @@ +{} diff --git a/pkg/overlay/testdata/upsert/multiple-levels-overlay.yaml b/pkg/overlay/testdata/upsert/multiple-levels-overlay.yaml new file mode 100644 index 0000000..cabfd75 --- /dev/null +++ b/pkg/overlay/testdata/upsert/multiple-levels-overlay.yaml @@ -0,0 +1,8 @@ +overlay: "1.0.0" +info: + title: test + version: "1.0" +actions: + - target: $.a.b.c.d.e + update: deep + upsert: true diff --git a/pkg/overlay/testdata/upsert/reject-filter-overlay.yaml b/pkg/overlay/testdata/upsert/reject-filter-overlay.yaml new file mode 100644 index 0000000..f9684b7 --- /dev/null +++ b/pkg/overlay/testdata/upsert/reject-filter-overlay.yaml @@ -0,0 +1,8 @@ +overlay: "1.0.0" +info: + title: test + version: "1.0" +actions: + - target: $[?(@.x)] + update: bar + upsert: true diff --git a/pkg/overlay/testdata/upsert/reject-recursive-overlay.yaml b/pkg/overlay/testdata/upsert/reject-recursive-overlay.yaml new file mode 100644 index 0000000..de11a4c --- /dev/null +++ b/pkg/overlay/testdata/upsert/reject-recursive-overlay.yaml @@ -0,0 +1,8 @@ +overlay: "1.0.0" +info: + title: test + version: "1.0" +actions: + - target: $..foo + update: bar + upsert: true diff --git a/pkg/overlay/testdata/upsert/reject-upsert-remove-overlay.yaml b/pkg/overlay/testdata/upsert/reject-upsert-remove-overlay.yaml new file mode 100644 index 0000000..5ca7be5 --- /dev/null +++ b/pkg/overlay/testdata/upsert/reject-upsert-remove-overlay.yaml @@ -0,0 +1,8 @@ +overlay: "1.0.0" +info: + title: test + version: "1.0" +actions: + - target: $.foo + upsert: true + remove: true diff --git a/pkg/overlay/testdata/upsert/reject-wildcard-overlay.yaml b/pkg/overlay/testdata/upsert/reject-wildcard-overlay.yaml new file mode 100644 index 0000000..9c2f6d0 --- /dev/null +++ b/pkg/overlay/testdata/upsert/reject-wildcard-overlay.yaml @@ -0,0 +1,8 @@ +overlay: "1.0.0" +info: + title: test + version: "1.0" +actions: + - target: $.*.foo + update: bar + upsert: true diff --git a/pkg/overlay/testdata/upsert/update-existing-expected.yaml b/pkg/overlay/testdata/upsert/update-existing-expected.yaml new file mode 100644 index 0000000..8acec2e --- /dev/null +++ b/pkg/overlay/testdata/upsert/update-existing-expected.yaml @@ -0,0 +1,2 @@ +spec: + existing: updated diff --git a/pkg/overlay/testdata/upsert/update-existing-input.yaml b/pkg/overlay/testdata/upsert/update-existing-input.yaml new file mode 100644 index 0000000..e8bfc1c --- /dev/null +++ b/pkg/overlay/testdata/upsert/update-existing-input.yaml @@ -0,0 +1,2 @@ +spec: + existing: value diff --git a/pkg/overlay/testdata/upsert/update-existing-overlay.yaml b/pkg/overlay/testdata/upsert/update-existing-overlay.yaml new file mode 100644 index 0000000..0472107 --- /dev/null +++ b/pkg/overlay/testdata/upsert/update-existing-overlay.yaml @@ -0,0 +1,8 @@ +overlay: "1.0.0" +info: + title: test + version: "1.0" +actions: + - target: $.spec.existing + update: updated + upsert: true diff --git a/pkg/overlay/validate.go b/pkg/overlay/validate.go index 5521e2a..d4fd1df 100644 --- a/pkg/overlay/validate.go +++ b/pkg/overlay/validate.go @@ -2,6 +2,8 @@ package overlay import ( "fmt" + "github.com/pb33f/jsonpath/pkg/jsonpath" + "github.com/pb33f/jsonpath/pkg/jsonpath/config" "net/url" "strings" ) @@ -51,6 +53,19 @@ func (o *Overlay) Validate() error { if action.Remove && !action.Update.IsZero() { errs = append(errs, fmt.Errorf("overlay action at index %d should not both set remove and define update", i)) } + + if action.Upsert { + if action.Remove { + errs = append(errs, fmt.Errorf("overlay action at index %d should not both set upsert and remove", i)) + } else if !action.Update.IsZero() { + p, err := jsonpath.NewPath(action.Target, config.WithPropertyNameExtension()) + if err != nil { + errs = append(errs, fmt.Errorf("overlay action at index %d has invalid target path: %w", i, err)) + } else if !p.IsSingular() { + errs = append(errs, fmt.Errorf("overlay action at index %d upsert requires a singular path (no wildcards, recursive descent, slices, or filters): %s", i, action.Target)) + } + } + } } return errs.Return()