diff --git a/pkg/yqlib/doc/Contains.md b/pkg/yqlib/doc/Contains.md new file mode 100644 index 00000000..b1ab09d4 --- /dev/null +++ b/pkg/yqlib/doc/Contains.md @@ -0,0 +1,85 @@ + +## Array contains array +Array is equal or subset of + +Given a sample.yml file of: +```yaml +- foobar +- foobaz +- blarp +``` +then +```bash +yq eval 'contains(["baz", "bar"])' sample.yml +``` +will output +```yaml +true +``` + +## Object included in array +Given a sample.yml file of: +```yaml +"foo": 12 +"bar": + - 1 + - 2 + - "barp": 12 + "blip": 13 +``` +then +```bash +yq eval 'contains({"bar": [{"barp": 12}]})' sample.yml +``` +will output +```yaml +true +``` + +## Object not included in array +Given a sample.yml file of: +```yaml +"foo": 12 +"bar": + - 1 + - 2 + - "barp": 12 + "blip": 13 +``` +then +```bash +yq eval 'contains({"foo": 12, "bar": [{"barp": 15}]})' sample.yml +``` +will output +```yaml +false +``` + +## String contains substring +Given a sample.yml file of: +```yaml +foobar +``` +then +```bash +yq eval 'contains("bar")' sample.yml +``` +will output +```yaml +true +``` + +## String equals string +Given a sample.yml file of: +```yaml +meow +``` +then +```bash +yq eval 'contains("meow")' sample.yml +``` +will output +```yaml +true +``` + diff --git a/pkg/yqlib/doc/With.md b/pkg/yqlib/doc/With.md index 5e21cc6b..a694c5cd 100644 --- a/pkg/yqlib/doc/With.md +++ b/pkg/yqlib/doc/With.md @@ -1,4 +1,4 @@ -Use the `with` operator to conveniently make multiple updates to a deeply nested path. +Use the `with` operator to conveniently make multiple updates to a deeply nested path, or to update array elements relatively to each other. ## Update and style Given a sample.yml file of: @@ -18,3 +18,43 @@ a: nested: 'newValue' ``` +## Update multiple deeply nested properties +Given a sample.yml file of: +```yaml +a: + deeply: + nested: value + other: thing +``` +then +```bash +yq eval 'with(.a.deeply ; .nested = "newValue" | .other= "newThing")' sample.yml +``` +will output +```yaml +a: + deeply: + nested: newValue + other: newThing +``` + +## Update array elements relatively +Given a sample.yml file of: +```yaml +myArray: + - a: apple + - a: banana +``` +then +```bash +yq eval 'with(.myArray[] ; .b = .a + " yum")' sample.yml +``` +will output +```yaml +myArray: + - a: apple + b: apple yum + - a: banana + b: banana yum +``` + diff --git a/pkg/yqlib/doc/headers/With.md b/pkg/yqlib/doc/headers/With.md index 13b440e0..5819ca37 100644 --- a/pkg/yqlib/doc/headers/With.md +++ b/pkg/yqlib/doc/headers/With.md @@ -1 +1 @@ -Use the `with` operator to conveniently make multiple updates to a deeply nested path. +Use the `with` operator to conveniently make multiple updates to a deeply nested path, or to update array elements relatively to each other. diff --git a/pkg/yqlib/expression_tokeniser.go b/pkg/yqlib/expression_tokeniser.go index e5183b80..c2426097 100644 --- a/pkg/yqlib/expression_tokeniser.go +++ b/pkg/yqlib/expression_tokeniser.go @@ -298,6 +298,7 @@ func initLexer() (*lex.Lexer, error) { lexer.Add([]byte(`any_c`), opToken(anyConditionOpType)) lexer.Add([]byte(`all`), opToken(allOpType)) lexer.Add([]byte(`all_c`), opToken(allConditionOpType)) + lexer.Add([]byte(`contains`), opToken(containsOpType)) lexer.Add([]byte(`split`), opToken(splitStringOpType)) lexer.Add([]byte(`keys`), opToken(keysOpType)) diff --git a/pkg/yqlib/lib.go b/pkg/yqlib/lib.go index a2971423..a9d9c088 100644 --- a/pkg/yqlib/lib.go +++ b/pkg/yqlib/lib.go @@ -62,6 +62,7 @@ var collectOpType = &operationType{Type: "COLLECT", NumArgs: 0, Precedence: 50, var anyOpType = &operationType{Type: "ANY", NumArgs: 0, Precedence: 50, Handler: anyOperator} var allOpType = &operationType{Type: "ALL", NumArgs: 0, Precedence: 50, Handler: allOperator} +var containsOpType = &operationType{Type: "CONTAINS", NumArgs: 1, Precedence: 50, Handler: containsOperator} var anyConditionOpType = &operationType{Type: "ANY_CONDITION", NumArgs: 1, Precedence: 50, Handler: anyOperator} var allConditionOpType = &operationType{Type: "ALL_CONDITION", NumArgs: 1, Precedence: 50, Handler: allOperator} diff --git a/pkg/yqlib/operator_contains.go b/pkg/yqlib/operator_contains.go new file mode 100644 index 00000000..d07a645e --- /dev/null +++ b/pkg/yqlib/operator_contains.go @@ -0,0 +1,111 @@ +package yqlib + +import ( + "fmt" + "strings" + + yaml "gopkg.in/yaml.v3" +) + +func containsOperator(d *dataTreeNavigator, context Context, expressionNode *ExpressionNode) (Context, error) { + return crossFunction(d, context.ReadOnlyClone(), expressionNode, containsWithNodes, false) +} + +func containsArrayElement(array *yaml.Node, item *yaml.Node) (bool, error) { + for index := 0; index < len(array.Content); index = index + 1 { + containedInArray, err := contains(array.Content[index], item) + if err != nil { + return false, err + } + if containedInArray { + return true, nil + } + } + return false, nil +} + +func containsArray(lhs *yaml.Node, rhs *yaml.Node) (bool, error) { + if rhs.Kind != yaml.SequenceNode { + return containsArrayElement(lhs, rhs) + } + for index := 0; index < len(rhs.Content); index = index + 1 { + itemInArray, err := containsArrayElement(lhs, rhs.Content[index]) + if err != nil { + return false, err + } + if !itemInArray { + return false, nil + } + } + return true, nil +} + +func containsObject(lhs *yaml.Node, rhs *yaml.Node) (bool, error) { + if rhs.Kind != yaml.MappingNode { + return false, nil + } + for index := 0; index < len(rhs.Content); index = index + 2 { + rhsKey := rhs.Content[index] + rhsValue := rhs.Content[index+1] + log.Debugf("Looking for %v in the lhs", rhsKey.Value) + lhsKeyIndex := findInArray(lhs, rhsKey) + log.Debugf("index is %v", lhsKeyIndex) + if lhsKeyIndex < 0 || lhsKeyIndex%2 != 0 { + return false, nil + } + lhsValue := lhs.Content[lhsKeyIndex+1] + log.Debugf("lhsValue is %v", lhsValue.Value) + + itemInArray, err := contains(lhsValue, rhsValue) + log.Debugf("rhsValue is %v", rhsValue.Value) + if err != nil { + return false, err + } + if !itemInArray { + return false, nil + } + } + return true, nil +} + +func containsScalars(lhs *yaml.Node, rhs *yaml.Node) (bool, error) { + if lhs.Tag == "!!str" { + return strings.Contains(lhs.Value, rhs.Value), nil + } + return lhs.Value == rhs.Value, nil +} + +func contains(lhs *yaml.Node, rhs *yaml.Node) (bool, error) { + switch lhs.Kind { + case yaml.MappingNode: + return containsObject(lhs, rhs) + case yaml.SequenceNode: + return containsArray(lhs, rhs) + case yaml.ScalarNode: + if rhs.Kind != yaml.ScalarNode || lhs.Tag != rhs.Tag { + return false, nil + } + if lhs.Tag == "!!null" { + return rhs.Tag == "!!null", nil + } + return containsScalars(lhs, rhs) + } + + return false, fmt.Errorf("%v not yet supported for contains", lhs.Tag) +} + +func containsWithNodes(d *dataTreeNavigator, context Context, lhs *CandidateNode, rhs *CandidateNode) (*CandidateNode, error) { + lhs.Node = unwrapDoc(lhs.Node) + rhs.Node = unwrapDoc(rhs.Node) + + if lhs.Node.Kind != rhs.Node.Kind { + return nil, fmt.Errorf("%v cannot check contained in %v", rhs.Node.Tag, lhs.Node.Tag) + } + + result, err := contains(lhs.Node, rhs.Node) + if err != nil { + return nil, err + } + + return createBooleanCandidate(lhs, result), nil +} diff --git a/pkg/yqlib/operator_contains_test.go b/pkg/yqlib/operator_contains_test.go new file mode 100644 index 00000000..116a778b --- /dev/null +++ b/pkg/yqlib/operator_contains_test.go @@ -0,0 +1,82 @@ +package yqlib + +import "testing" + +var containsOperatorScenarios = []expressionScenario{ + { + skipDoc: true, + expression: `null | contains(~)`, + expected: []string{ + "D0, P[], (!!bool)::true\n", + }, + }, + { + skipDoc: true, + expression: `3 | contains(3)`, + expected: []string{ + "D0, P[], (!!bool)::true\n", + }, + }, + { + skipDoc: true, + expression: `3 | contains(32)`, + expected: []string{ + "D0, P[], (!!bool)::false\n", + }, + }, + { + description: "Array contains array", + subdescription: "Array is equal or subset of", + document: `["foobar", "foobaz", "blarp"]`, + expression: `contains(["baz", "bar"])`, + expected: []string{ + "D0, P[], (!!bool)::true\n", + }, + }, + { + skipDoc: true, + expression: `["dog", "cat", "giraffe"] | contains(["camel"])`, + expected: []string{ + "D0, P[], (!!bool)::false\n", + }, + }, + { + description: "Object included in array", + document: `{"foo": 12, "bar":[1,2,{"barp":12, "blip":13}]}`, + expression: `contains({"bar": [{"barp": 12}]})`, + expected: []string{ + "D0, P[], (!!bool)::true\n", + }, + }, + { + description: "Object not included in array", + document: `{"foo": 12, "bar":[1,2,{"barp":12, "blip":13}]}`, + expression: `contains({"foo": 12, "bar": [{"barp": 15}]})`, + expected: []string{ + "D0, P[], (!!bool)::false\n", + }, + }, + { + description: "String contains substring", + document: `"foobar"`, + expression: `contains("bar")`, + expected: []string{ + "D0, P[], (!!bool)::true\n", + }, + }, + { + description: "String equals string", + document: `"meow"`, + expression: `contains("meow")`, + expected: []string{ + "D0, P[], (!!bool)::true\n", + }, + }, +} + +func TestContainsOperatorScenarios(t *testing.T) { + for _, tt := range containsOperatorScenarios { + testScenario(t, &tt) + } + documentScenarios(t, "Contains", containsOperatorScenarios) +} diff --git a/pkg/yqlib/operator_with_test.go b/pkg/yqlib/operator_with_test.go index c856a4b4..06e7f89c 100644 --- a/pkg/yqlib/operator_with_test.go +++ b/pkg/yqlib/operator_with_test.go @@ -11,6 +11,22 @@ var withOperatorScenarios = []expressionScenario{ "D0, P[], (doc)::a: {deeply: {nested: 'newValue'}}\n", }, }, + { + description: "Update multiple deeply nested properties", + document: `a: {deeply: {nested: value, other: thing}}`, + expression: `with(.a.deeply ; .nested = "newValue" | .other= "newThing")`, + expected: []string{ + "D0, P[], (doc)::a: {deeply: {nested: newValue, other: newThing}}\n", + }, + }, + { + description: "Update array elements relatively", + document: `myArray: [{a: apple},{a: banana}]`, + expression: `with(.myArray[] ; .b = .a + " yum")`, + expected: []string{ + "D0, P[], (doc)::myArray: [{a: apple, b: apple yum}, {a: banana, b: banana yum}]\n", + }, + }, } func TestWithOperatorScenarios(t *testing.T) { diff --git a/release_notes.txt b/release_notes.txt index 5969faed..2c9a01c6 100644 --- a/release_notes.txt +++ b/release_notes.txt @@ -10,6 +10,7 @@ Sorry for any inconvenience caused!. - New `with` operator for making multiple changes to a given path +- New `contains` operator, works like the `jq` equivalent - Subtract operator now supports subtracting elements from arrays! - Fixed Swapping values using variables #934 - Github Action now properly supports multiline output #936, thanks @pjxiao