From 86c2b03630163947edb5471c13967e58c3a3b6ab Mon Sep 17 00:00:00 2001 From: Mike Farah Date: Sat, 25 Jun 2022 12:46:24 +1000 Subject: [PATCH] Added error operator for custom validation #1259 --- pkg/yqlib/doc/operators/error.md | 56 ++++++++++++++++++++++++ pkg/yqlib/doc/operators/headers/error.md | 3 ++ pkg/yqlib/expression_tokeniser.go | 2 + pkg/yqlib/lib.go | 1 + pkg/yqlib/operator_error.go | 20 +++++++++ pkg/yqlib/operator_error_test.go | 40 +++++++++++++++++ 6 files changed, 122 insertions(+) create mode 100644 pkg/yqlib/doc/operators/error.md create mode 100644 pkg/yqlib/doc/operators/headers/error.md create mode 100644 pkg/yqlib/operator_error.go create mode 100644 pkg/yqlib/operator_error_test.go diff --git a/pkg/yqlib/doc/operators/error.md b/pkg/yqlib/doc/operators/error.md new file mode 100644 index 00000000..74a8671e --- /dev/null +++ b/pkg/yqlib/doc/operators/error.md @@ -0,0 +1,56 @@ +# Error + +Use this operation to short-circuit expressions. Useful for validation. + +{% hint style="warning" %} +Note that versions prior to 4.18 require the 'eval/e' command to be specified. + +`yq e ` +{% endhint %} + +## Validate a particular value +Given a sample.yml file of: +```yaml +a: hello +``` +then +```bash +yq 'select(.a == "howdy") or error(".a [" + .a + "] is not howdy!")' sample.yml +``` +will output +```bash +Error: .a [hello] is not howdy! +``` + +## Validate the environment variable is a number - invalid +Running +```bash +numberOfCats="please" yq --null-input 'env(numberOfCats) | select(tag == "!!int") or error("numberOfCats is not a number :(")' +``` +will output +```bash +Error: numberOfCats is not a number :( +``` + +## Validate the environment variable is a number - valid +`with` can be a convenient way of encapsulating validation. + +Given a sample.yml file of: +```yaml +name: Bob +favouriteAnimal: cat +``` +then +```bash +numberOfCats="3" yq ' + with(env(numberOfCats); select(tag == "!!int") or error("numberOfCats is not a number :(")) | + .numPets = env(numberOfCats) +' sample.yml +``` +will output +```yaml +name: Bob +favouriteAnimal: cat +numPets: 3 +``` + diff --git a/pkg/yqlib/doc/operators/headers/error.md b/pkg/yqlib/doc/operators/headers/error.md new file mode 100644 index 00000000..a9fb1a5c --- /dev/null +++ b/pkg/yqlib/doc/operators/headers/error.md @@ -0,0 +1,3 @@ +# Error + +Use this operation to short-circuit expressions. Useful for validation. diff --git a/pkg/yqlib/expression_tokeniser.go b/pkg/yqlib/expression_tokeniser.go index 657bfffb..860032d1 100644 --- a/pkg/yqlib/expression_tokeniser.go +++ b/pkg/yqlib/expression_tokeniser.go @@ -363,6 +363,8 @@ func initLexer() (*lex.Lexer, error) { lexer.Add([]byte(`tz`), opToken(tzOpType)) lexer.Add([]byte(`with_dtf`), opToken(withDtFormatOpType)) + lexer.Add([]byte(`error`), opToken(errorOpType)) + lexer.Add([]byte(`toyaml\([0-9]+\)`), encodeWithIndent(YamlOutputFormat)) lexer.Add([]byte(`to_yaml\([0-9]+\)`), encodeWithIndent(YamlOutputFormat)) diff --git a/pkg/yqlib/lib.go b/pkg/yqlib/lib.go index dd328dcf..6753a9a1 100644 --- a/pkg/yqlib/lib.go +++ b/pkg/yqlib/lib.go @@ -91,6 +91,7 @@ var columnOpType = &operationType{Type: "LINE", NumArgs: 0, Precedence: 50, Hand var collectOpType = &operationType{Type: "COLLECT", NumArgs: 1, Precedence: 50, Handler: collectOperator} var mapOpType = &operationType{Type: "MAP", NumArgs: 1, Precedence: 50, Handler: mapOperator} +var errorOpType = &operationType{Type: "ERROR", NumArgs: 1, Precedence: 50, Handler: errorOperator} var pickOpType = &operationType{Type: "PICK", NumArgs: 1, Precedence: 50, Handler: pickOperator} var evalOpType = &operationType{Type: "EVAL", NumArgs: 1, Precedence: 50, Handler: evalOperator} var mapValuesOpType = &operationType{Type: "MAP_VALUES", NumArgs: 1, Precedence: 50, Handler: mapValuesOperator} diff --git a/pkg/yqlib/operator_error.go b/pkg/yqlib/operator_error.go new file mode 100644 index 00000000..8b0e3a9f --- /dev/null +++ b/pkg/yqlib/operator_error.go @@ -0,0 +1,20 @@ +package yqlib + +import ( + "fmt" +) + +func errorOperator(d *dataTreeNavigator, context Context, expressionNode *ExpressionNode) (Context, error) { + + log.Debugf("-- errorOperation") + + rhs, err := d.GetMatchingNodes(context.ReadOnlyClone(), expressionNode.RHS) + if err != nil { + return Context{}, err + } + errorMessage := "aborted" + if rhs.MatchingNodes.Len() > 0 { + errorMessage = rhs.MatchingNodes.Front().Value.(*CandidateNode).Node.Value + } + return Context{}, fmt.Errorf(errorMessage) +} diff --git a/pkg/yqlib/operator_error_test.go b/pkg/yqlib/operator_error_test.go new file mode 100644 index 00000000..845686d6 --- /dev/null +++ b/pkg/yqlib/operator_error_test.go @@ -0,0 +1,40 @@ +package yqlib + +import "testing" + +const validationExpression = ` + with(env(numberOfCats); select(tag == "!!int") or error("numberOfCats is not a number :(")) | + .numPets = env(numberOfCats) +` + +var errorOperatorScenarios = []expressionScenario{ + { + description: "Validate a particular value", + document: `a: hello`, + expression: `select(.a == "howdy") or error(".a [" + .a + "] is not howdy!")`, + expectedError: ".a [hello] is not howdy!", + }, + { + description: "Validate the environment variable is a number - invalid", + environmentVariables: map[string]string{"numberOfCats": "please"}, + expression: `env(numberOfCats) | select(tag == "!!int") or error("numberOfCats is not a number :(")`, + expectedError: "numberOfCats is not a number :(", + }, + { + description: "Validate the environment variable is a number - valid", + subdescription: "`with` can be a convenient way of encapsulating validation.", + environmentVariables: map[string]string{"numberOfCats": "3"}, + document: "name: Bob\nfavouriteAnimal: cat\n", + expression: validationExpression, + expected: []string{ + "D0, P[], (doc)::name: Bob\nfavouriteAnimal: cat\nnumPets: 3\n", + }, + }, +} + +func TestErrorOperatorScenarios(t *testing.T) { + for _, tt := range errorOperatorScenarios { + testScenario(t, &tt) + } + documentOperatorScenarios(t, "error", errorOperatorScenarios) +}