diff --git a/pkg/yqlib/doc/operators/first.md b/pkg/yqlib/doc/operators/first.md new file mode 100644 index 00000000..65599092 --- /dev/null +++ b/pkg/yqlib/doc/operators/first.md @@ -0,0 +1,345 @@ + +## First matching element from array +Given a sample.yml file of: +```yaml +- a: banana +- a: cat +- a: apple +``` +then +```bash +yq 'first(.a == "cat")' sample.yml +``` +will output +```yaml +a: cat +``` + +## First matching element from array with multiple matches +Given a sample.yml file of: +```yaml +- a: banana +- a: cat +- a: apple +- a: cat +``` +then +```bash +yq 'first(.a == "cat")' sample.yml +``` +will output +```yaml +a: cat +``` + +## First matching element from array with numeric condition +Given a sample.yml file of: +```yaml +- a: 10 +- a: 100 +- a: 1 +``` +then +```bash +yq 'first(.a > 50)' sample.yml +``` +will output +```yaml +a: 100 +``` + +## First matching element from array with boolean condition +Given a sample.yml file of: +```yaml +- a: false +- a: true +- a: false +``` +then +```bash +yq 'first(.a == true)' sample.yml +``` +will output +```yaml +a: true +``` + +## First matching element from array with null values +Given a sample.yml file of: +```yaml +- a: null +- a: cat +- a: apple +``` +then +```bash +yq 'first(.a != null)' sample.yml +``` +will output +```yaml +a: cat +``` + +## First matching element from array with complex condition +Given a sample.yml file of: +```yaml +- a: dog + b: 5 +- a: cat + b: 3 +- a: apple + b: 7 +``` +then +```bash +yq 'first(.b > 4)' sample.yml +``` +will output +```yaml +a: dog +b: 5 +``` + +## First matching element from map +Given a sample.yml file of: +```yaml +x: + a: banana +y: + a: cat +z: + a: apple +``` +then +```bash +yq 'first(.a == "cat")' sample.yml +``` +will output +```yaml +a: cat +``` + +## First matching element from map with numeric condition +Given a sample.yml file of: +```yaml +x: + a: 10 +y: + a: 100 +z: + a: 1 +``` +then +```bash +yq 'first(.a > 50)' sample.yml +``` +will output +```yaml +a: 100 +``` + +## First matching element from nested structure +Given a sample.yml file of: +```yaml +items: + - a: banana + - a: cat + - a: apple +``` +then +```bash +yq '.items | first(.a == "cat")' sample.yml +``` +will output +```yaml +a: cat +``` + +## First matching element with no matches +Given a sample.yml file of: +```yaml +- a: banana +- a: cat +- a: apple +``` +then +```bash +yq 'first(.a == "dog")' sample.yml +``` +will output +```yaml +``` + +## First matching element from empty array +Given a sample.yml file of: +```yaml +[] +``` +then +```bash +yq 'first(.a == "cat")' sample.yml +``` +will output +```yaml +``` + +## First matching element from scalar node +Given a sample.yml file of: +```yaml +hello +``` +then +```bash +yq 'first(. == "hello")' sample.yml +``` +will output +```yaml +``` + +## First matching element from null node +Given a sample.yml file of: +```yaml +null +``` +then +```bash +yq 'first(. == "hello")' sample.yml +``` +will output +```yaml +``` + +## First matching element with string condition +Given a sample.yml file of: +```yaml +- a: banana +- a: cat +- a: apple +``` +then +```bash +yq 'first(.a | test("^c"))' sample.yml +``` +will output +```yaml +a: cat +``` + +## First matching element with length condition +Given a sample.yml file of: +```yaml +- a: hi +- a: hello +- a: world +``` +then +```bash +yq 'first(.a | length > 4)' sample.yml +``` +will output +```yaml +a: hello +``` + +## First matching element from array of strings +Given a sample.yml file of: +```yaml +- banana +- cat +- apple +``` +then +```bash +yq 'first(. == "cat")' sample.yml +``` +will output +```yaml +cat +``` + +## First matching element from array of numbers +Given a sample.yml file of: +```yaml +- 10 +- 100 +- 1 +``` +then +```bash +yq 'first(. > 50)' sample.yml +``` +will output +```yaml +100 +``` + +## First element with no RHS from array +Given a sample.yml file of: +```yaml +- 10 +- 100 +- 1 +``` +then +```bash +yq 'first' sample.yml +``` +will output +```yaml +10 +``` + +## First element with no RHS from array of maps +Given a sample.yml file of: +```yaml +- a: 10 +- a: 100 +``` +then +```bash +yq 'first' sample.yml +``` +will output +```yaml +a: 10 +``` + +## No RHS on empty array returns nothing +Given a sample.yml file of: +```yaml +[] +``` +then +```bash +yq 'first' sample.yml +``` +will output +```yaml +``` + +## No RHS on scalar returns nothing +Given a sample.yml file of: +```yaml +hello +``` +then +```bash +yq 'first' sample.yml +``` +will output +```yaml +``` + +## No RHS on null returns nothing +Given a sample.yml file of: +```yaml +null +``` +then +```bash +yq 'first' sample.yml +``` +will output +```yaml +``` + diff --git a/pkg/yqlib/expression_parser.go b/pkg/yqlib/expression_parser.go index 399ef9c7..eaa5b1c1 100644 --- a/pkg/yqlib/expression_parser.go +++ b/pkg/yqlib/expression_parser.go @@ -54,6 +54,12 @@ func (p *expressionParserImpl) createExpressionTree(postFixPath []*Operation) (* switch numArgs { case 1: if len(stack) < 1 { + // Allow certain unary ops to accept zero args by interpreting missing RHS as nil + // TODO - make this more general on OperationType + if Operation.OperationType == firstOpType { + // no RHS provided; proceed without popping + break + } return nil, fmt.Errorf("'%v' expects 1 arg but received none", strings.TrimSpace(Operation.StringValue)) } remaining, rhs := stack[:len(stack)-1], stack[len(stack)-1] diff --git a/pkg/yqlib/lexer_participle.go b/pkg/yqlib/lexer_participle.go index 2a47c97d..958ae747 100644 --- a/pkg/yqlib/lexer_participle.go +++ b/pkg/yqlib/lexer_participle.go @@ -118,6 +118,7 @@ var participleYqRules = []*participleYqRule{ simpleOp("sort_?by", sortByOpType), simpleOp("sort", sortOpType), + simpleOp("first", firstOpType), simpleOp("reverse", reverseOpType), diff --git a/pkg/yqlib/operation.go b/pkg/yqlib/operation.go index 77bdf206..5d790df1 100644 --- a/pkg/yqlib/operation.go +++ b/pkg/yqlib/operation.go @@ -143,6 +143,7 @@ var delPathsOpType = &operationType{Type: "DEL_PATHS", NumArgs: 1, Precedence: 5 var explodeOpType = &operationType{Type: "EXPLODE", NumArgs: 1, Precedence: 52, Handler: explodeOperator, CheckForPostTraverse: true} var sortByOpType = &operationType{Type: "SORT_BY", NumArgs: 1, Precedence: 52, Handler: sortByOperator, CheckForPostTraverse: true} +var firstOpType = &operationType{Type: "FIRST", NumArgs: 1, Precedence: 52, Handler: firstOperator, CheckForPostTraverse: true} var reverseOpType = &operationType{Type: "REVERSE", NumArgs: 0, Precedence: 52, Handler: reverseOperator, CheckForPostTraverse: true} var sortOpType = &operationType{Type: "SORT", NumArgs: 0, Precedence: 52, Handler: sortOperator, CheckForPostTraverse: true} var shuffleOpType = &operationType{Type: "SHUFFLE", NumArgs: 0, Precedence: 52, Handler: shuffleOperator, CheckForPostTraverse: true} diff --git a/pkg/yqlib/operator_first.go b/pkg/yqlib/operator_first.go new file mode 100644 index 00000000..cdc4d289 --- /dev/null +++ b/pkg/yqlib/operator_first.go @@ -0,0 +1,51 @@ +package yqlib + +import "container/list" + +func firstOperator(d *dataTreeNavigator, context Context, expressionNode *ExpressionNode) (Context, error) { + results := list.New() + + for el := context.MatchingNodes.Front(); el != nil; el = el.Next() { + candidate := el.Value.(*CandidateNode) + + // If no RHS expression is provided, simply return the first entry in candidate.Content + if expressionNode == nil || expressionNode.RHS == nil { + if len(candidate.Content) > 0 { + results.PushBack(candidate.Content[0]) + } + continue + } + + splatted, err := splat(context.SingleChildContext(candidate), traversePreferences{}) + if err != nil { + return Context{}, err + } + + for splatEl := splatted.MatchingNodes.Front(); splatEl != nil; splatEl = splatEl.Next() { + splatCandidate := splatEl.Value.(*CandidateNode) + // Create a new context for this splatted candidate + splatContext := context.SingleChildContext(splatCandidate) + // Evaluate the RHS expression against this splatted candidate + rhs, err := d.GetMatchingNodes(splatContext, expressionNode.RHS) + if err != nil { + return Context{}, err + } + + includeResult := false + + for resultEl := rhs.MatchingNodes.Front(); resultEl != nil; resultEl = resultEl.Next() { + result := resultEl.Value.(*CandidateNode) + includeResult = isTruthyNode(result) + if includeResult { + break + } + } + if includeResult { + results.PushBack(splatCandidate) + break + } + } + + } + return context.ChildContext(results), nil +} diff --git a/pkg/yqlib/operator_first_test.go b/pkg/yqlib/operator_first_test.go new file mode 100644 index 00000000..3805b17f --- /dev/null +++ b/pkg/yqlib/operator_first_test.go @@ -0,0 +1,184 @@ +package yqlib + +import "testing" + +var firstOperatorScenarios = []expressionScenario{ + { + description: "First matching element from array", + document: "[{a: banana},{a: cat},{a: apple}]", + expression: `first(.a == "cat")`, + expected: []string{ + "D0, P[1], (!!map)::{a: cat}\n", + }, + }, + { + description: "First matching element from array with multiple matches", + document: "[{a: banana},{a: cat},{a: apple},{a: cat}]", + expression: `first(.a == "cat")`, + expected: []string{ + "D0, P[1], (!!map)::{a: cat}\n", + }, + }, + { + description: "First matching element from array with numeric condition", + document: "[{a: 10},{a: 100},{a: 1}]", + expression: `first(.a > 50)`, + expected: []string{ + "D0, P[1], (!!map)::{a: 100}\n", + }, + }, + { + description: "First matching element from array with boolean condition", + document: "[{a: false},{a: true},{a: false}]", + expression: `first(.a == true)`, + expected: []string{ + "D0, P[1], (!!map)::{a: true}\n", + }, + }, + { + description: "First matching element from array with null values", + document: "[{a: null},{a: cat},{a: apple}]", + expression: `first(.a != null)`, + expected: []string{ + "D0, P[1], (!!map)::{a: cat}\n", + }, + }, + { + description: "First matching element from array with complex condition", + document: "[{a: dog, b: 5},{a: cat, b: 3},{a: apple, b: 7}]", + expression: `first(.b > 4)`, + expected: []string{ + "D0, P[0], (!!map)::{a: dog, b: 5}\n", + }, + }, + { + description: "First matching element from map", + document: "x: {a: banana}\ny: {a: cat}\nz: {a: apple}", + expression: `first(.a == "cat")`, + expected: []string{ + "D0, P[y], (!!map)::{a: cat}\n", + }, + }, + { + description: "First matching element from map with numeric condition", + document: "x: {a: 10}\ny: {a: 100}\nz: {a: 1}", + expression: `first(.a > 50)`, + expected: []string{ + "D0, P[y], (!!map)::{a: 100}\n", + }, + }, + { + description: "First matching element from nested structure", + document: "items: [{a: banana},{a: cat},{a: apple}]", + expression: `.items | first(.a == "cat")`, + expected: []string{ + "D0, P[items 1], (!!map)::{a: cat}\n", + }, + }, + { + description: "First matching element with no matches", + document: "[{a: banana},{a: cat},{a: apple}]", + expression: `first(.a == "dog")`, + expected: []string{ + // No output expected when no matches + }, + }, + { + description: "First matching element from empty array", + document: "[]", + expression: `first(.a == "cat")`, + expected: []string{ + // No output expected when array is empty + }, + }, + { + description: "First matching element from scalar node", + document: "hello", + expression: `first(. == "hello")`, + expected: []string{ + // No output expected when node is scalar (no content to splat) + }, + }, + { + description: "First matching element from null node", + document: "null", + expression: `first(. == "hello")`, + expected: []string{ + // No output expected when node is null (no content to splat) + }, + }, + { + description: "First matching element with string condition", + document: "[{a: banana},{a: cat},{a: apple}]", + expression: `first(.a | test("^c"))`, + expected: []string{ + "D0, P[1], (!!map)::{a: cat}\n", + }, + }, + { + description: "First matching element with length condition", + document: "[{a: hi},{a: hello},{a: world}]", + expression: `first(.a | length > 4)`, + expected: []string{ + "D0, P[1], (!!map)::{a: hello}\n", + }, + }, + { + description: "First matching element from array of strings", + document: "[banana, cat, apple]", + expression: `first(. == "cat")`, + expected: []string{ + "D0, P[1], (!!str)::cat\n", + }, + }, + { + description: "First matching element from array of numbers", + document: "[10, 100, 1]", + expression: `first(. > 50)`, + expected: []string{ + "D0, P[1], (!!int)::100\n", + }, + }, + // New tests for no RHS (return first child) + { + description: "First element with no RHS from array", + document: "[10, 100, 1]", + expression: `first`, + expected: []string{ + "D0, P[0], (!!int)::10\n", + }, + }, + { + description: "First element with no RHS from array of maps", + document: "[{a: 10},{a: 100}]", + expression: `first`, + expected: []string{ + "D0, P[0], (!!map)::{a: 10}\n", + }, + }, + { + description: "No RHS on empty array returns nothing", + document: "[]", + expression: `first`, + expected: []string{}, + }, + { + description: "No RHS on scalar returns nothing", + document: "hello", + expression: `first`, + expected: []string{}, + }, + { + description: "No RHS on null returns nothing", + document: "null", + expression: `first`, + expected: []string{}, + }, +} + +func TestFirstOperatorScenarios(t *testing.T) { + for _, tt := range firstOperatorScenarios { + testScenario(t, &tt) + } + documentOperatorScenarios(t, "first", firstOperatorScenarios) +}