Skip to content

Commit

Permalink
Added divide and modulo operators (#1593)
Browse files Browse the repository at this point in the history
* Added divide operator (#49)

* Tidy up divide operator logic

* Added modulo operator

* Fix divide test typo

* Add divide by zero test

* Handle int modulo by 0 and add tests

* Tidy up divide/modulo operator node creation

* Fix linter errors
  • Loading branch information
teejaded committed Mar 15, 2023
1 parent 360a47f commit a466821
Show file tree
Hide file tree
Showing 8 changed files with 557 additions and 0 deletions.
56 changes: 56 additions & 0 deletions pkg/yqlib/doc/operators/divide.md
@@ -0,0 +1,56 @@

## String split
Given a sample.yml file of:
```yaml
a: cat_meow
b: _
```
then
```bash
yq '.c = .a / .b' sample.yml
```
will output
```yaml
a: cat_meow
b: _
c:
- cat
- meow
```

## Number division
The result during division is calculated as a float

Given a sample.yml file of:
```yaml
a: 12
b: 2.5
```
then
```bash
yq '.a = .a / .b' sample.yml
```
will output
```yaml
a: 4.8
b: 2.5
```

## Number division by zero
Dividing by zero results in +Inf or -Inf

Given a sample.yml file of:
```yaml
a: 1
b: -1
```
then
```bash
yq '.a = .a / 0 | .b = .b / 0' sample.yml
```
will output
```yaml
a: !!float +Inf
b: !!float -Inf
```

72 changes: 72 additions & 0 deletions pkg/yqlib/doc/operators/modulo.md
@@ -0,0 +1,72 @@

## Number modulo - int
If the lhs and rhs are ints then the expression will be calculated with ints.

Given a sample.yml file of:
```yaml
a: 13
b: 2
```
then
```bash
yq '.a = .a % .b' sample.yml
```
will output
```yaml
a: 1
b: 2
```

## Number modulo - float
If the lhs or rhs are floats then the expression will be calculated with floats.

Given a sample.yml file of:
```yaml
a: 12
b: 2.5
```
then
```bash
yq '.a = .a % .b' sample.yml
```
will output
```yaml
a: !!float 2
b: 2.5
```

## Number modulo - int by zero
If the lhs is an int and rhs is a 0 the result is an error.

Given a sample.yml file of:
```yaml
a: 1
b: 0
```
then
```bash
yq '.a = .a % .b' sample.yml
```
will output
```bash
Error: cannot modulo by 0
```

## Number modulo - float by zero
If the lhs is a float and rhs is a 0 the result is NaN.

Given a sample.yml file of:
```yaml
a: 1.1
b: 0
```
then
```bash
yq '.a = .a % .b' sample.yml
```
will output
```yaml
a: !!float NaN
b: 0
```

4 changes: 4 additions & 0 deletions pkg/yqlib/lexer_participle.go
Expand Up @@ -210,6 +210,10 @@ var participleYqRules = []*participleYqRule{
{"MultiplyAssign", `\*=[\+|\?cdn]*`, multiplyWithPrefs(multiplyAssignOpType), 0},
{"Multiply", `\*[\+|\?cdn]*`, multiplyWithPrefs(multiplyOpType), 0},

{"Divide", `\/`, opToken(divideOpType), 0},

{"Modulo", `%`, opToken(moduloOpType), 0},

{"AddAssign", `\+=`, opToken(addAssignOpType), 0},
{"Add", `\+`, opToken(addOpType), 0},

Expand Down
4 changes: 4 additions & 0 deletions pkg/yqlib/lib.go
Expand Up @@ -62,6 +62,10 @@ var assignAliasOpType = &operationType{Type: "ASSIGN_ALIAS", NumArgs: 2, Precede
var multiplyOpType = &operationType{Type: "MULTIPLY", NumArgs: 2, Precedence: 42, Handler: multiplyOperator}
var multiplyAssignOpType = &operationType{Type: "MULTIPLY_ASSIGN", NumArgs: 2, Precedence: 42, Handler: multiplyAssignOperator}

var divideOpType = &operationType{Type: "DIVIDE", NumArgs: 2, Precedence: 42, Handler: divideOperator}

var moduloOpType = &operationType{Type: "MODULO", NumArgs: 2, Precedence: 42, Handler: moduloOperator}

var addOpType = &operationType{Type: "ADD", NumArgs: 2, Precedence: 42, Handler: addOperator}
var subtractOpType = &operationType{Type: "SUBTRACT", NumArgs: 2, Precedence: 42, Handler: subtractOperator}
var alternativeOpType = &operationType{Type: "ALTERNATIVE", NumArgs: 2, Precedence: 42, Handler: alternativeOperator}
Expand Down
78 changes: 78 additions & 0 deletions pkg/yqlib/operator_divide.go
@@ -0,0 +1,78 @@
package yqlib

import (
"fmt"
"strconv"
"strings"

yaml "gopkg.in/yaml.v3"
)

func divideOperator(d *dataTreeNavigator, context Context, expressionNode *ExpressionNode) (Context, error) {
log.Debugf("Divide operator")

return crossFunction(d, context.ReadOnlyClone(), expressionNode, divide, false)
}

func divide(d *dataTreeNavigator, context Context, lhs *CandidateNode, rhs *CandidateNode) (*CandidateNode, error) {
lhs.Node = unwrapDoc(lhs.Node)
rhs.Node = unwrapDoc(rhs.Node)

lhsNode := lhs.Node

if lhsNode.Tag == "!!null" {
return nil, fmt.Errorf("%v (%v) cannot be divided by %v (%v)", lhsNode.Tag, lhs.GetNicePath(), rhs.Node.Tag, rhs.GetNicePath())
}

target := &yaml.Node{}

if lhsNode.Kind == yaml.ScalarNode && rhs.Node.Kind == yaml.ScalarNode {
if err := divideScalars(target, lhsNode, rhs.Node); err != nil {
return nil, err
}
} else {
return nil, fmt.Errorf("%v (%v) cannot be divided by %v (%v)", lhsNode.Tag, lhs.GetNicePath(), rhs.Node.Tag, rhs.GetNicePath())
}

return lhs.CreateReplacement(target), nil
}

func divideScalars(target *yaml.Node, lhs *yaml.Node, rhs *yaml.Node) error {
lhsTag := lhs.Tag
rhsTag := guessTagFromCustomType(rhs)
lhsIsCustom := false
if !strings.HasPrefix(lhsTag, "!!") {
// custom tag - we have to have a guess
lhsTag = guessTagFromCustomType(lhs)
lhsIsCustom = true
}

if lhsTag == "!!str" && rhsTag == "!!str" {
res := split(lhs.Value, rhs.Value)
target.Kind = res.Kind
target.Tag = res.Tag
target.Content = res.Content
} else if (lhsTag == "!!int" || lhsTag == "!!float") && (rhsTag == "!!int" || rhsTag == "!!float") {
target.Kind = yaml.ScalarNode
target.Style = lhs.Style

lhsNum, err := strconv.ParseFloat(lhs.Value, 64)
if err != nil {
return err
}
rhsNum, err := strconv.ParseFloat(rhs.Value, 64)
if err != nil {
return err
}
quotient := lhsNum / rhsNum
if lhsIsCustom {
target.Tag = lhs.Tag
} else {
target.Tag = "!!float"
}
target.Value = fmt.Sprintf("%v", quotient)
} else {
return fmt.Errorf("%v cannot be divided by %v", lhsTag, rhsTag)
}
return nil
}
130 changes: 130 additions & 0 deletions pkg/yqlib/operator_divide_test.go
@@ -0,0 +1,130 @@
package yqlib

import (
"testing"
)

var divideOperatorScenarios = []expressionScenario{
{
skipDoc: true,
document: `[{a: foo_bar, b: _}, {a: 4, b: 2}]`,
expression: ".[] | .a / .b",
expected: []string{
"D0, P[0 a], (!!seq)::- foo\n- bar\n",
"D0, P[1 a], (!!float)::2\n",
},
},
{
skipDoc: true,
document: `{}`,
expression: "(.a / .b) as $x | .",
expected: []string{
"D0, P[], (doc)::{}\n",
},
},
{
description: "String split",
document: `{a: cat_meow, b: _}`,
expression: `.c = .a / .b`,
expected: []string{
"D0, P[], (doc)::{a: cat_meow, b: _, c: [cat, meow]}\n",
},
},
{
description: "Number division",
subdescription: "The result during division is calculated as a float",
document: `{a: 12, b: 2.5}`,
expression: `.a = .a / .b`,
expected: []string{
"D0, P[], (doc)::{a: 4.8, b: 2.5}\n",
},
},
{
description: "Number division by zero",
subdescription: "Dividing by zero results in +Inf or -Inf",
document: `{a: 1, b: -1}`,
expression: `.a = .a / 0 | .b = .b / 0`,
expected: []string{
"D0, P[], (doc)::{a: !!float +Inf, b: !!float -Inf}\n",
},
},
{
skipDoc: true,
description: "Custom types: that are really strings",
document: "a: !horse cat_meow\nb: !goat _",
expression: `.a = .a / .b`,
expected: []string{
"D0, P[], (doc)::a: !horse\n - cat\n - meow\nb: !goat _\n",
},
},
{
skipDoc: true,
description: "Custom types: that are really numbers",
document: "a: !horse 1.2\nb: !goat 2.3",
expression: `.a = .a / .b`,
expected: []string{
"D0, P[], (doc)::a: !horse 0.5217391304347826\nb: !goat 2.3\n",
},
},
{
skipDoc: true,
document: "a: 2\nb: !goat 2.3",
expression: `.a = .a / .b`,
expected: []string{
"D0, P[], (doc)::a: 0.8695652173913044\nb: !goat 2.3\n",
},
},
{
skipDoc: true,
description: "Custom types: that are really ints",
document: "a: !horse 2\nb: !goat 3",
expression: `.a = .a / .b`,
expected: []string{
"D0, P[], (doc)::a: !horse 0.6666666666666666\nb: !goat 3\n",
},
},
{
skipDoc: true,
description: "Keep anchors",
document: "a: &horse [1]",
expression: `.a[1] = .a[0] / 2`,
expected: []string{
"D0, P[], (doc)::a: &horse [1, 0.5]\n",
},
},
{
skipDoc: true,
description: "Divide int by string",
document: "a: 123\nb: '2'",
expression: `.a / .b`,
expectedError: "!!int cannot be divided by !!str",
},
{
skipDoc: true,
description: "Divide string by int",
document: "a: 2\nb: '123'",
expression: `.b / .a`,
expectedError: "!!str cannot be divided by !!int",
},
{
skipDoc: true,
description: "Divide map by int",
document: "a: {\"a\":1}\nb: 2",
expression: `.a / .b`,
expectedError: "!!map (a) cannot be divided by !!int (b)",
},
{
skipDoc: true,
description: "Divide array by str",
document: "a: [1,2]\nb: '2'",
expression: `.a / .b`,
expectedError: "!!seq (a) cannot be divided by !!str (b)",
},
}

func TestDivideOperatorScenarios(t *testing.T) {
for _, tt := range divideOperatorScenarios {
testScenario(t, &tt)
}
documentOperatorScenarios(t, "divide", divideOperatorScenarios)
}

0 comments on commit a466821

Please sign in to comment.