From d7f5e64adf7c4cf33f074273e2b6a1543f38504f Mon Sep 17 00:00:00 2001 From: Scott McGowan Date: Wed, 26 Jan 2022 07:18:24 +0000 Subject: [PATCH 1/6] refactor: simplify code adding unit tests for script engine --- README.md | 27 +- helper_test.go | 108 ++++ jsonpath.go | 143 +---- jsonpath_test.go | 562 ++---------------- options.go | 29 + options_test.go | 73 +++ script/standard/README.md | 48 ++ script/standard/complex_operators.go | 9 +- script/standard/complex_operators_test.go | 42 +- script/standard/{standard.go => engine.go} | 119 ++-- .../{standard_test.go => engine_test.go} | 283 +++++++-- script/standard/errors.go | 10 +- script/standard/errors_test.go | 18 + script/standard/expression.go | 44 ++ script/standard/expression_test.go | 160 +++++ script/standard/helper.go | 14 +- script/standard/helper_test.go | 14 + script/standard/operators_test.go | 22 + selector.go | 91 +++ selector_test.go | 384 ++++++++++++ test/filter_test.go | 2 +- 21 files changed, 1380 insertions(+), 822 deletions(-) create mode 100644 helper_test.go create mode 100644 options.go create mode 100644 options_test.go create mode 100644 script/standard/README.md rename script/standard/{standard.go => engine.go} (65%) rename script/standard/{standard_test.go => engine_test.go} (77%) create mode 100644 script/standard/errors_test.go create mode 100644 script/standard/expression.go create mode 100644 script/standard/expression_test.go create mode 100644 selector.go create mode 100644 selector_test.go diff --git a/README.md b/README.md index eb74fd1..2304b63 100644 --- a/README.md +++ b/README.md @@ -196,24 +196,15 @@ For maps, the keys will be sorted into alphabetical order and they will be used For strings instead of returning an array of characters instead will return a substring. For example if you applied `[0:3]` to the string `string` it would return `str`. -## Supported standard evaluation operations - -| symbol | name | supported types | example | notes | -| --- | --- | --- | --- | --- | -| == | equals | any | 1 == 1 returns true | | -| != | not equals | any | 1 != 2 returns true | | -| * | multiplication | int\|float | 2*2 returns 4 | | -| / | division | int\|float | 10/5 returns 2 | if you supply two whole numbers you will only get a whole number response, even if there is a remainder i.e. 10/4 would return 2, not 2.5. to include remainders you would need to have the numerator as a float i.e. 10.0/4 would return 2.5 | -| + | addition | int\|float | 2+2 returns 4 | | -| - | subtraction | int\|float | 2-2 returns 0 | | -| % | remainder | int\|float | 5 % 2 returns 1 | this operator will divide the numerator by the denominator and then return the remainder | -| > | greater than | int\|float | 1 > 0 returns true | | -| >= | greater than or equal to | int\|float | 1 >= 1 returns true | | -| < | less than | int\|float | 1 < 2 returns true | | -| <= | less than or equal to | int\|float | 1 <= 1 returns true | | -| && | combine and | expression\|bool | true&&false returns false | evaluate two expressions that return true or false, and return true if both are true | -| \|\| | combine or | expression\|bool | true\|\|false returns true | evaluate two expressions that return true or false, and return true if either are true | -| (...) | sub-expression | expression | (1+2)*3 returns 9 | allows you to isolate a sub-expression so it will be evaluated first separate from the rest of the expression | +## Script Engine + +The library supports scripts and filters using a [standard script engine](script/standard/README.md) included with this library. + +Additionally, a custom script engine can be created and passed as an additional option when compiling the JSONPath selector + +```golang + +``` ## History diff --git a/helper_test.go b/helper_test.go new file mode 100644 index 0000000..992456f --- /dev/null +++ b/helper_test.go @@ -0,0 +1,108 @@ +package jsonpath + +import ( + "github.com/evilmonkeyinc/jsonpath/option" + "github.com/evilmonkeyinc/jsonpath/script" +) + +type sampleData struct { + Expensive float64 `json:"expensive"` + Store *storeData `json:"store"` +} + +type storeData struct { + Book []*bookData `json:"book"` +} + +type bookData struct { + Author string `json:"author"` + Category string `json:"category"` + ISBN string `json:"isbn"` + Price float64 `json:"price"` + Title string `json:"title"` +} + +var sampleDataObject *sampleData = &sampleData{ + Expensive: 10, + Store: &storeData{ + Book: []*bookData{ + { + Category: "reference", + Author: "Nigel Rees", + Title: "Sayings of the Century", + Price: 8.95, + }, + { + Category: "fiction", + Author: "Evelyn Waugh", + Title: "Sword of Honour", + Price: 12.99, + }, + { + Category: "fiction", + Author: "Herman Melville", + Title: "Moby Dick", + ISBN: "0-553-21311-3", + Price: 8.99, + }, + { + Category: "fiction", + Author: "J. R. R. Tolkien", + Title: "The Lord of the Rings", + ISBN: "0-395-19395-8", + Price: 22.99, + }, + }, + }, +} + +var sampleDataString string = ` +{ + "store": { + "book": [{ + "category": "reference", + "author": "Nigel Rees", + "title": "Sayings of the Century", + "price": 8.95 + }, + { + "category": "fiction", + "author": "Evelyn Waugh", + "title": "Sword of Honour", + "price": 12.99 + }, + { + "category": "fiction", + "author": "Herman Melville", + "title": "Moby Dick", + "isbn": "0-553-21311-3", + "price": 8.99 + }, + { + "category": "fiction", + "author": "J. R. R. Tolkien", + "title": "The Lord of the Rings", + "isbn": "0-395-19395-8", + "price": 22.99 + } + ], + "bicycle": { + "color": "red", + "price": 19.95 + } + }, + "expensive": 10 +} +` + +type testScriptEngine struct { + value interface{} +} + +func (engine *testScriptEngine) Compile(expression string, options *option.QueryOptions) (script.CompiledExpression, error) { + return nil, nil +} + +func (engine *testScriptEngine) Evaluate(root, current interface{}, expression string, options *option.QueryOptions) (interface{}, error) { + return nil, nil +} diff --git a/jsonpath.go b/jsonpath.go index 6f0f390..db0f448 100644 --- a/jsonpath.go +++ b/jsonpath.go @@ -1,150 +1,59 @@ package jsonpath import ( - "encoding/json" - "fmt" - "strconv" - "strings" - - "github.com/evilmonkeyinc/jsonpath/option" - "github.com/evilmonkeyinc/jsonpath/script" "github.com/evilmonkeyinc/jsonpath/script/standard" "github.com/evilmonkeyinc/jsonpath/token" ) // Compile will compile the JSONPath selector -func Compile(selector string) (*Selector, error) { - engine := new(standard.ScriptEngine) - - if selector == "$[?(@.key<3),?(@.key>6)]" { - // TODO - selector = "$[?(@.key<3),?(@.key>6)]" +func Compile(selector string, options ...Option) (*Selector, error) { + jsonPath := &Selector{ + selector: selector, } - jsonPath := &Selector{} - if err := jsonPath.compile(selector, engine); err != nil { - return nil, getInvalidJSONPathSelectorWithReason(selector, err) + for _, option := range options { + if err := option.Apply(jsonPath); err != nil { + return nil, err + } } - return jsonPath, nil -} - -// Query will return the result of the JSONPath selector applied against the specified JSON data. -func Query(selector string, jsonData interface{}) (interface{}, error) { - jsonPath, err := Compile(selector) - if err != nil { - return nil, getInvalidJSONPathSelectorWithReason(selector, err) + // Set defaults if options were not used + if jsonPath.engine == nil { + jsonPath.engine = new(standard.ScriptEngine) } - return jsonPath.Query(jsonData) -} - -// QueryString will return the result of the JSONPath selector applied against the specified JSON data. -func QueryString(selector string, jsonData string) (interface{}, error) { - jsonPath, err := Compile(selector) + tokenStrings, err := token.Tokenize(jsonPath.selector) if err != nil { return nil, getInvalidJSONPathSelectorWithReason(selector, err) } - return jsonPath.QueryString(jsonData) -} - -// Selector represents a compiled JSONPath selector -// and exposes functions to query JSON data and objects. -type Selector struct { - Options *option.QueryOptions - engine script.Engine - tokens []token.Token - selector string -} - -// String returns the compiled selector string representation -func (query *Selector) String() string { - jsonPath := "" - for _, token := range query.tokens { - jsonPath += fmt.Sprintf("%s", token) - } - return jsonPath -} - -func (query *Selector) compile(selector string, engine script.Engine) error { - query.engine = engine - query.selector = selector - - tokenStrings, err := token.Tokenize(selector) - if err != nil { - return err - } tokens := make([]token.Token, len(tokenStrings)) for idx, tokenString := range tokenStrings { - token, err := token.Parse(tokenString, query.engine, query.Options) + token, err := token.Parse(tokenString, jsonPath.engine, jsonPath.Options) if err != nil { - return err + return nil, getInvalidJSONPathSelectorWithReason(selector, err) } tokens[idx] = token } - query.tokens = tokens + jsonPath.tokens = tokens - return nil + return jsonPath, nil } -// Query will return the result of the JSONPath query applied against the specified JSON data. -func (query *Selector) Query(root interface{}) (interface{}, error) { - if len(query.tokens) == 0 { - return nil, getInvalidJSONPathSelector(query.selector) - } - - tokens := make([]token.Token, 0) - if len(query.tokens) > 1 { - tokens = query.tokens[1:] - } - - found, err := query.tokens[0].Apply(root, root, tokens) +// Query will return the result of the JSONPath selector applied against the specified JSON data. +func Query(selector string, jsonData interface{}, options ...Option) (interface{}, error) { + jsonPath, err := Compile(selector, options...) if err != nil { - return nil, err + return nil, getInvalidJSONPathSelectorWithReason(selector, err) } - return found, nil + return jsonPath.Query(jsonData) } -// QueryString will return the result of the JSONPath query applied against the specified JSON data. -func (query *Selector) QueryString(jsonData string) (interface{}, error) { - jsonData = strings.TrimSpace(jsonData) - if jsonData == "" { - return nil, getInvalidJSONData(errDataIsUnexpectedTypeOrNil) - } - - var root interface{} - - if strings.HasPrefix(jsonData, "{") && strings.HasSuffix(jsonData, "}") { - // object - root = make(map[string]interface{}) - if err := json.Unmarshal([]byte(jsonData), &root); err != nil { - return nil, getInvalidJSONData(err) - } - } else if strings.HasPrefix(jsonData, "[") && strings.HasSuffix(jsonData, "]") { - // array - root = make([]interface{}, 0) - if err := json.Unmarshal([]byte(jsonData), &root); err != nil { - return nil, getInvalidJSONData(err) - } - } else if len(jsonData) > 2 && strings.HasPrefix(jsonData, "\"") && strings.HasPrefix(jsonData, "\"") { - // string - root = jsonData[1 : len(jsonData)-1] - } else if strings.ToLower(jsonData) == "true" { - // bool true - root = true - } else if strings.ToLower(jsonData) == "false" { - // bool false - root = false - } else if val, err := strconv.ParseInt(jsonData, 10, 64); err == nil { - // integer - root = val - } else if val, err := strconv.ParseFloat(jsonData, 64); err == nil { - // float - root = val - } else { - return nil, getInvalidJSONData(errDataIsUnexpectedTypeOrNil) +// QueryString will return the result of the JSONPath selector applied against the specified JSON data. +func QueryString(selector string, jsonData string, options ...Option) (interface{}, error) { + jsonPath, err := Compile(selector, options...) + if err != nil { + return nil, getInvalidJSONPathSelectorWithReason(selector, err) } - - return query.Query(root) + return jsonPath.QueryString(jsonData) } diff --git a/jsonpath_test.go b/jsonpath_test.go index 0968135..39b40d9 100644 --- a/jsonpath_test.go +++ b/jsonpath_test.go @@ -4,100 +4,9 @@ import ( "fmt" "testing" - "github.com/evilmonkeyinc/jsonpath/script" "github.com/stretchr/testify/assert" ) -type sampleData struct { - Expensive float64 `json:"expensive"` - Store *storeData `json:"store"` -} - -type storeData struct { - Book []*bookData `json:"book"` -} - -type bookData struct { - Author string `json:"author"` - Category string `json:"category"` - ISBN string `json:"isbn"` - Price float64 `json:"price"` - Title string `json:"title"` -} - -var sampleDataObject *sampleData = &sampleData{ - Expensive: 10, - Store: &storeData{ - Book: []*bookData{ - { - Category: "reference", - Author: "Nigel Rees", - Title: "Sayings of the Century", - Price: 8.95, - }, - { - Category: "fiction", - Author: "Evelyn Waugh", - Title: "Sword of Honour", - Price: 12.99, - }, - { - Category: "fiction", - Author: "Herman Melville", - Title: "Moby Dick", - ISBN: "0-553-21311-3", - Price: 8.99, - }, - { - Category: "fiction", - Author: "J. R. R. Tolkien", - Title: "The Lord of the Rings", - ISBN: "0-395-19395-8", - Price: 22.99, - }, - }, - }, -} - -var sampleDataString string = ` -{ - "store": { - "book": [{ - "category": "reference", - "author": "Nigel Rees", - "title": "Sayings of the Century", - "price": 8.95 - }, - { - "category": "fiction", - "author": "Evelyn Waugh", - "title": "Sword of Honour", - "price": 12.99 - }, - { - "category": "fiction", - "author": "Herman Melville", - "title": "Moby Dick", - "isbn": "0-553-21311-3", - "price": 8.99 - }, - { - "category": "fiction", - "author": "J. R. R. Tolkien", - "title": "The Lord of the Rings", - "isbn": "0-395-19395-8", - "price": 22.99 - } - ], - "bicycle": { - "color": "red", - "price": 19.95 - } - }, - "expensive": 10 -} -` - // Tests designed after the examples in the specification document // // https://goessner.net/articles/JsonPath/ @@ -475,6 +384,7 @@ func Test_Compile(t *testing.T) { type input struct { selector string + options []Option } type expected struct { @@ -497,6 +407,12 @@ func Test_Compile(t *testing.T) { { input: input{ selector: "@.[1, 2]", + options: []Option{ + OptionFunction(func(selector *Selector) error { + assert.Equal(t, "@.[1, 2]", selector.selector) + return nil + }), + }, }, expected: expected{ tokens: 2, @@ -510,11 +426,40 @@ func Test_Compile(t *testing.T) { tokens: 2, }, }, + { + input: input{ + selector: "@.[1,(]", + }, + expected: expected{ + err: "invalid JSONPath selector '@.[1,(]' invalid token. '[1,(]' does not match any token format", + }, + }, + { + input: input{ + selector: "@.length<1", + }, + expected: expected{ + tokens: 2, + }, + }, + { + input: input{ + selector: "this wont matter", + options: []Option{ + OptionFunction(func(selector *Selector) error { + return fmt.Errorf("option error") + }), + }, + }, + expected: expected{ + err: "option error", + }, + }, } for idx, test := range tests { t.Run(fmt.Sprintf("%d", idx), func(t *testing.T) { - selector, err := Compile(test.input.selector) + selector, err := Compile(test.input.selector, test.input.options...) if test.expected.err != "" { assert.Nil(t, selector) assert.EqualError(t, err, test.expected.err) @@ -780,436 +725,3 @@ func Test_Query(t *testing.T) { }) } } - -func Test_Selector_String(t *testing.T) { - - tests := []struct { - input string - expected string - }{ - { - input: "$.store.book[*].author", - expected: "$['store']['book'][*]['author']", - }, - { - input: "$..author", - expected: "$..['author']", - }, - { - input: "$.store.*", - expected: "$['store'][*]", - }, - { - input: "$.store..price", - expected: "$['store']..['price']", - }, - { - input: "$..book[2]", - expected: "$..['book'][2]", - }, - { - input: "$..book[(@.length-1)]", - expected: "$..['book'][(@.length-1)]", - }, - { - input: "$..book[-1:]", - expected: "$..['book'][-1:]", - }, - { - input: "$..book[0,1]", - expected: "$..['book'][0,1]", - }, - { - input: "$..book[:2]", - expected: "$..['book'][:2]", - }, - { - input: "$..book[?(@.isbn)]", - expected: "$..['book'][?(@.isbn)]", - }, - { - input: "$..book[?(@.price<10)]", - expected: "$..['book'][?(@.price<10)]", - }, - { - input: "$..*", - expected: "$..[*]", - }, - { - input: "$.store. book[0].author", - expected: "$['store']['book'][0]['author']", - }, - } - - for idx, test := range tests { - t.Run(fmt.Sprintf("%d", idx), func(t *testing.T) { - compiled, _ := Compile(test.input) - assert.Equal(t, test.expected, compiled.String()) - }) - } - -} - -func Test_Selector_compile(t *testing.T) { - - type input struct { - selector string - engine script.Engine - } - - type expected struct { - err string - tokens int - } - - tests := []struct { - input input - expected expected - }{ - { - input: input{ - selector: "", - }, - expected: expected{ - err: "unexpected token '' at index 0", - }, - }, - { - input: input{ - selector: "@.[1,(]", - }, - expected: expected{ - err: "invalid token. '[1,(]' does not match any token format", - }, - }, - { - input: input{ - selector: "@.length<1", - }, - expected: expected{ - tokens: 2, - }, - }, - } - - for idx, test := range tests { - t.Run(fmt.Sprintf("%d", idx), func(t *testing.T) { - selector := &Selector{} - actual := selector.compile(test.input.selector, test.input.engine) - if test.expected.err == "" { - assert.Nil(t, actual) - } else { - assert.EqualError(t, actual, test.expected.err) - } - - assert.Len(t, selector.tokens, test.expected.tokens) - }) - } -} - -func Test_Selector_QueryString(t *testing.T) { - - sampleQuery, _ := Compile("$.expensive") - altSampleQuery, _ := Compile("$..author") - lengthQuery, _ := Compile("$.length") - rootQuery, _ := Compile("$") - - type input struct { - selector *Selector - jsonData string - } - - type expected struct { - value interface{} - err string - } - - tests := []struct { - input input - expected expected - }{ - { - input: input{ - selector: sampleQuery, - }, - expected: expected{ - err: "invalid data. unexpected type or nil", - }, - }, - { - input: input{ - selector: rootQuery, - jsonData: "42", - }, - expected: expected{ - value: int64(42), - }, - }, - { - input: input{ - selector: rootQuery, - jsonData: "3.14", - }, - expected: expected{ - value: float64(3.14), - }, - }, - { - input: input{ - selector: rootQuery, - jsonData: "true", - }, - expected: expected{ - value: true, - }, - }, - { - input: input{ - selector: rootQuery, - jsonData: "false", - }, - expected: expected{ - value: false, - }, - }, - { - input: input{ - selector: rootQuery, - jsonData: "not a json string", - }, - expected: expected{ - err: "invalid data. unexpected type or nil", - }, - }, - { - input: input{ - selector: rootQuery, - jsonData: `"json string"`, - }, - expected: expected{ - value: "json string", - }, - }, - { - input: input{ - selector: &Selector{ - selector: "invalid", - }, - jsonData: "{}", - }, - expected: expected{ - err: "invalid JSONPath selector 'invalid'", - }, - }, - { - input: input{ - selector: sampleQuery, - jsonData: `{"key"}`, - }, - expected: expected{ - err: "invalid data. invalid character '}' after object key", - }, - }, - { - input: input{ - selector: sampleQuery, - jsonData: "{}", - }, - expected: expected{ - err: "key: invalid token key 'expensive' not found", - }, - }, - { - input: input{ - selector: sampleQuery, - jsonData: `{"expensive": "test"}`, - }, - expected: expected{ - value: "test", - }, - }, - { - input: input{ - selector: sampleQuery, - jsonData: sampleDataString, - }, - expected: expected{ - value: int64(10), - }, - }, - { - input: input{ - selector: altSampleQuery, - jsonData: sampleDataString, - }, - expected: expected{ - value: []interface{}{ - "Nigel Rees", - "Evelyn Waugh", - "Herman Melville", - "J. R. R. Tolkien", - }, - }, - }, - { - input: input{ - selector: lengthQuery, - jsonData: `[1,2,3]`, - }, - expected: expected{ - value: int64(3), - }, - }, - { - input: input{ - selector: lengthQuery, - jsonData: `[1,2,]`, - }, - expected: expected{ - err: "invalid data. invalid character ']' looking for beginning of value", - }, - }, - } - - for idx, test := range tests { - t.Run(fmt.Sprintf("%d", idx), func(t *testing.T) { - value, err := test.input.selector.QueryString(test.input.jsonData) - - if test.expected.err != "" { - assert.EqualError(t, err, test.expected.err) - } else { - assert.Nil(t, err) - } - - if expectArray, ok := test.expected.value.([]interface{}); ok { - assert.ElementsMatch(t, expectArray, value) - } else { - assert.EqualValues(t, test.expected.value, value) - } - }) - } -} - -func Test_Selector_Query(t *testing.T) { - - sampleSelector, _ := Compile("$.expensive") - altSampleSelector, _ := Compile("$..author") - - type input struct { - selector *Selector - jsonData interface{} - } - - type expected struct { - value interface{} - err string - } - - tests := []struct { - input input - expected expected - }{ - { - input: input{ - selector: sampleSelector, - jsonData: make(chan bool, 1), - }, - expected: expected{ - err: "key: invalid token target. expected [map] got [chan]", - }, - }, - { - input: input{ - selector: sampleSelector, - jsonData: "not something that can be marshaled", - }, - expected: expected{ - err: "key: invalid token target. expected [map] got [string]", - }, - }, - { - input: input{ - selector: sampleSelector, - jsonData: &storeData{}, - }, - expected: expected{ - err: "key: invalid token key 'expensive' not found", - }, - }, - { - input: input{ - selector: &Selector{ - selector: "invalid", - }, - jsonData: &sampleData{}, - }, - expected: expected{ - err: "invalid JSONPath selector 'invalid'", - }, - }, - { - input: input{ - selector: sampleSelector, - jsonData: &bookData{}, - }, - expected: expected{ - err: "key: invalid token key 'expensive' not found", - }, - }, - { - input: input{ - selector: sampleSelector, - jsonData: &sampleData{ - Expensive: 15, - }, - }, - expected: expected{ - value: float64(15), - }, - }, - { - input: input{ - selector: sampleSelector, - jsonData: sampleDataObject, - }, - expected: expected{ - value: float32(10), - }, - }, - { - input: input{ - selector: altSampleSelector, - jsonData: sampleDataObject, - }, - expected: expected{ - value: []interface{}{ - "Nigel Rees", - "Evelyn Waugh", - "Herman Melville", - "J. R. R. Tolkien", - }, - }, - }, - } - - for idx, test := range tests { - t.Run(fmt.Sprintf("%d", idx), func(t *testing.T) { - value, err := test.input.selector.Query(test.input.jsonData) - - if test.expected.err != "" { - assert.EqualError(t, err, test.expected.err) - } else { - assert.Nil(t, err) - } - - if expectArray, ok := test.expected.value.([]interface{}); ok { - assert.NotNil(t, value) - if value != nil { - assert.ElementsMatch(t, expectArray, value) - } - } else { - assert.EqualValues(t, test.expected.value, value) - } - }) - } -} diff --git a/options.go b/options.go new file mode 100644 index 0000000..48b040c --- /dev/null +++ b/options.go @@ -0,0 +1,29 @@ +package jsonpath + +import ( + "github.com/evilmonkeyinc/jsonpath/script" +) + +// OptionFunction function that can be used as a compile or query option +type OptionFunction func(selector *Selector) error + +// Apply the option to the Selector +func (fn OptionFunction) Apply(selector *Selector) error { + return fn(selector) +} + +// Option allows to set compile and query options when calling Compile +type Option interface { + // Apply the option to the Selector + Apply(selector *Selector) error +} + +// ScriptEngine allows you to set a custom script Engine for the JSONPath selector +func ScriptEngine(engine script.Engine) Option { + return OptionFunction(func(selector *Selector) error { + if selector.engine == nil { + selector.engine = engine + } + return nil + }) +} diff --git a/options_test.go b/options_test.go new file mode 100644 index 0000000..f64955c --- /dev/null +++ b/options_test.go @@ -0,0 +1,73 @@ +package jsonpath + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +// Test that OptionFunction func conforms to Option interface +var _ Option = OptionFunction(func(selector *Selector) error { return nil }) + +func Test_OptionFunction(t *testing.T) { + + t.Run("nil", func(t *testing.T) { + called := false + option := OptionFunction(func(selector *Selector) error { + called = true + return nil + }) + err := option.Apply(nil) + assert.Nil(t, err) + assert.True(t, called) + }) + + t.Run("error", func(t *testing.T) { + called := false + option := OptionFunction(func(selector *Selector) error { + called = true + return fmt.Errorf("fail") + }) + err := option.Apply(nil) + assert.EqualError(t, err, "fail") + assert.True(t, called) + }) + +} + +func Test_ScriptEngine(t *testing.T) { + + t.Run("first", func(t *testing.T) { + + engine := &testScriptEngine{value: 1} + option := ScriptEngine(engine) + selector := &Selector{} + + err := option.Apply(selector) + assert.Nil(t, err) + assert.Equal(t, engine, selector.engine) + + }) + t.Run("second", func(t *testing.T) { + + engine1 := &testScriptEngine{value: 1} + engine2 := &testScriptEngine{value: 2} + + option1 := ScriptEngine(engine1) + option2 := ScriptEngine(engine2) + + selector := &Selector{} + + err := option1.Apply(selector) + assert.Nil(t, err) + + err = option2.Apply(selector) + assert.Nil(t, err) + + assert.Equal(t, engine1, selector.engine) + assert.NotEqual(t, engine2, selector.engine) + + }) + +} diff --git a/script/standard/README.md b/script/standard/README.md new file mode 100644 index 0000000..366d513 --- /dev/null +++ b/script/standard/README.md @@ -0,0 +1,48 @@ +# Standard Script Engine + +The standard script engine is a basic implementation of the script.Engine interface that is used as the default script engine + +## Supported Operations + +|operator|name|supported types| +|-|-|-| +|`\|\|`|logical OR|boolean| +|`&&`|logical AND|boolean| +|`==`|equals|number\|string| +|`!=`|not equals|number\|string| +|`<=`|less than or equal to|number| +|`>=`|greater than or equal to|number| +|`<`|less than|number| +|`>`|greater than|number| +|`=~`|regex|string| +|`+`|plus/addition|number| +|`-`|minus/subtraction|number| +|`**`|power|number| +|`*`|multiplication|number| +|`/`|division|number| +|`%`|modulus|integer| + +### Regex + +The regex operator will perform a regex match check using the left side argument as the input and the right as the regex pattern. + +The right side pattern must be passed as a string, between single or double quotes, to ensure that no characters are mistaken for other operators + +> the regex operation is handled by the standard [`regexp`](https://pkg.go.dev/regexp) golang library `Match` function. + +## Special Parameters + +The following symbols/tokens have special meaning when used in script expressions and will be replaced before the expression is evaluated. The symbols used within a string, between single or double quotes, will not be replaced. + +|symbol|name|replacement| +|-|-|-| +|`$`|root|the root json data node| +|`@`|current|the current json data node| +|`nil`|nil|`nil`| +|`null`|null|`nil`| + +Using the root or current symbol allows to embed a JSONPath selector within an expression and it is expected that any argument that includes these characters should be a valid selector. + +The nil and null tokens can be used interchangeably to represent a nil value. + +> remember that the @ character has different meaning in subscripts than it does in filters diff --git a/script/standard/complex_operators.go b/script/standard/complex_operators.go index 9af1c99..1979417 100644 --- a/script/standard/complex_operators.go +++ b/script/standard/complex_operators.go @@ -20,14 +20,17 @@ func (op *regexOperator) Evaluate(parameters map[string]interface{}) (interface{ return nil, err } + if len(b) > 1 && strings.HasPrefix(b, "'") && strings.HasSuffix(b, "'") { + b = b[1 : len(b)-1] + } + pattern, err := getString(op.arg2, parameters) if err != nil { return nil, err } - if strings.HasPrefix(pattern, "/") { - end := strings.LastIndex(pattern, "/") - pattern = pattern[1:end] + if len(pattern) > 1 && strings.HasPrefix(pattern, "'") && strings.HasSuffix(pattern, "'") { + pattern = pattern[1 : len(pattern)-1] } regex, err := regexp.Compile(pattern) diff --git a/script/standard/complex_operators_test.go b/script/standard/complex_operators_test.go index 8709775..aa90756 100644 --- a/script/standard/complex_operators_test.go +++ b/script/standard/complex_operators_test.go @@ -55,34 +55,34 @@ func Test_regexOperator(t *testing.T) { { input: operatorTestInput{ operator: ®exOperator{ - arg1: "1", - arg2: `/\d/i`, + arg1: "string", + arg2: `\d`, }, }, expected: operatorTestExpected{ - value: true, + value: false, }, }, { input: operatorTestInput{ operator: ®exOperator{ arg1: "string", - arg2: `\d`, + arg2: `\`, }, }, expected: operatorTestExpected{ - value: false, + err: "invalid argument. expected a valid regexp", }, }, { input: operatorTestInput{ operator: ®exOperator{ - arg1: "string", - arg2: `\`, + arg1: "'1'", + arg2: `"\d"`, }, }, expected: operatorTestExpected{ - err: "invalid argument. expected a valid regexp", + value: true, }, }, } @@ -144,6 +144,32 @@ func Test_selectorOperator(t *testing.T) { value: true, }, }, + { + input: operatorTestInput{ + operator: currentKeyOperator, + paramters: map[string]interface{}{ + "@": map[string]interface{}{ + "notkey": true, + }, + }, + }, + expected: operatorTestExpected{ + err: "key: invalid token key 'key' not found", + }, + }, + { + input: operatorTestInput{ + operator: currentKeyOperator, + paramters: map[string]interface{}{ + "@": map[string]interface{}{ + "key": "'value'", + }, + }, + }, + expected: operatorTestExpected{ + value: "'value'", + }, + }, } batchOperatorTests(t, tests) } diff --git a/script/standard/standard.go b/script/standard/engine.go similarity index 65% rename from script/standard/standard.go rename to script/standard/engine.go index 78a4dfa..65b902e 100644 --- a/script/standard/standard.go +++ b/script/standard/engine.go @@ -8,6 +8,8 @@ import ( "github.com/evilmonkeyinc/jsonpath/script" ) +// TODO : add tests for what is in readme +// TODO : update readme to give more details, maybe add readme to this package and link from main // TODO : add support for bitwise operators | &^ ^ & << >> after + and - var defaultTokens []string = []string{ "||", "&&", @@ -37,6 +39,19 @@ func (engine *ScriptEngine) Compile(expression string, options *option.QueryOpti }, nil } +// Evaluate return the result of the expression evaluation +func (engine *ScriptEngine) Evaluate(root, current interface{}, expression string, options *option.QueryOptions) (interface{}, error) { + compiled, err := engine.Compile(expression, options) + if err != nil { + return nil, err + } + evaluation, err := compiled.Evaluate(root, current) + if err != nil { + return nil, err + } + return evaluation, nil +} + func (engine *ScriptEngine) buildOperators(expression string, tokens []string, options *option.QueryOptions) (operator, error) { nextToken := tokens[0] expression = strings.TrimSpace(expression) @@ -59,6 +74,7 @@ func (engine *ScriptEngine) buildOperators(expression string, tokens []string, o } // check right for more tokens, or use raw string as input + // right check needs done first as some expressions just have left sides arg2Str := strings.TrimSpace(expression[idx+(len(nextToken)):]) if arg2Str == "" && (nextToken != "$" && nextToken != "@") { if len(tokens) == 1 { @@ -68,25 +84,9 @@ func (engine *ScriptEngine) buildOperators(expression string, tokens []string, o return engine.buildOperators(expression, tokens[1:], options) } - var arg2 interface{} = arg2Str - if op, err := engine.buildOperators(arg2Str, tokens, options); err != nil { + arg2, err := engine.parseArgument(arg2Str, tokens, options) + if err != nil { return nil, err - } else if op != nil { - arg2 = op - } else { - arg2Str = strings.TrimSpace(arg2Str) - arg2 = arg2Str - if strings.HasPrefix(arg2Str, "[") && strings.HasSuffix(arg2Str, "]") { - val := make([]interface{}, 0) - if err := json.Unmarshal([]byte(arg2Str), &val); err == nil { - arg2 = val - } - } else if strings.HasPrefix(arg2Str, "{") && strings.HasSuffix(arg2Str, "}") { - val := make(map[string]interface{}) - if err := json.Unmarshal([]byte(arg2Str), &val); err == nil { - arg2 = val - } - } } // check left for more tokens, or use raw string as input @@ -99,30 +99,18 @@ func (engine *ScriptEngine) buildOperators(expression string, tokens []string, o return engine.buildOperators(expression, tokens[1:], options) } - var arg1 interface{} = arg1Str - if op, err := engine.buildOperators(arg1Str, tokens, options); err != nil { + arg1, err := engine.parseArgument(arg1Str, tokens, options) + if err != nil { return nil, err - } else if op != nil { - arg1 = op - } else { - arg1Str = strings.TrimSpace(arg1Str) - arg1 = arg1Str - if strings.HasPrefix(arg1Str, "[") && strings.HasSuffix(arg1Str, "]") { - val := make([]interface{}, 0) - if err := json.Unmarshal([]byte(arg1Str), &val); err == nil { - arg1 = val - } - } else if strings.HasPrefix(arg1Str, "{") && strings.HasSuffix(arg1Str, "}") { - val := make(map[string]interface{}) - if err := json.Unmarshal([]byte(arg1Str), &val); err == nil { - arg1 = val - } - } } switch nextToken { case "@", "$": - return newSelectorOperator(expression, engine, options) + selector, err := newSelectorOperator(expression, engine, options) + if err != nil { + return nil, err + } + return selector, nil case "=~": return ®exOperator{ arg1: arg1, @@ -205,52 +193,25 @@ func (engine *ScriptEngine) buildOperators(expression string, tokens []string, o return nil, errUnsupportedOperator } -// Evaluate return the result of the expression evaluation -func (engine *ScriptEngine) Evaluate(root, current interface{}, expression string, options *option.QueryOptions) (interface{}, error) { - compiled, err := engine.Compile(expression, options) - if err != nil { +func (engine *ScriptEngine) parseArgument(argument string, tokens []string, options *option.QueryOptions) (interface{}, error) { + if op, err := engine.buildOperators(argument, tokens, options); err != nil { return nil, err - } - return compiled.Evaluate(root, current) -} - -type compiledExpression struct { - expression string - rootOperator operator - engine *ScriptEngine - options *option.QueryOptions -} - -func (compiled *compiledExpression) Evaluate(root, current interface{}) (interface{}, error) { - expression := compiled.expression - if expression == "" { - return nil, getInvalidExpressionEmptyError() - } - parameters := map[string]interface{}{ - "$": root, - "@": current, - "nil": nil, - "null": nil, + } else if op != nil { + return op, nil } - if compiled.rootOperator == nil { - if val, ok := parameters[expression]; ok { - return val, nil + argument = strings.TrimSpace(argument) + var arg interface{} = argument + if strings.HasPrefix(argument, "[") && strings.HasSuffix(argument, "]") { + val := make([]interface{}, 0) + if err := json.Unmarshal([]byte(argument), &val); err == nil { + arg = val } - - if number, err := getNumber(expression, parameters); err == nil { - return number, nil - } else if boolean, err := getBoolean(expression, parameters); err == nil { - return boolean, nil + } else if strings.HasPrefix(argument, "{") && strings.HasSuffix(argument, "}") { + val := make(map[string]interface{}) + if err := json.Unmarshal([]byte(argument), &val); err == nil { + arg = val } - - return expression, nil } - - value, err := compiled.rootOperator.Evaluate(parameters) - if err != nil { - return nil, err - } - - return value, nil + return arg, nil } diff --git a/script/standard/standard_test.go b/script/standard/engine_test.go similarity index 77% rename from script/standard/standard_test.go rename to script/standard/engine_test.go index e808e9a..268f485 100644 --- a/script/standard/standard_test.go +++ b/script/standard/engine_test.go @@ -62,6 +62,14 @@ func Test_ScriptEngine_Compile(t *testing.T) { }, }, }, + { + input: input{ + expression: ".$", + }, + expected: expected{ + err: "unexpected token '.' at index 0", + }, + }, } for idx, test := range tests { @@ -78,6 +86,138 @@ func Test_ScriptEngine_Compile(t *testing.T) { } } +func Test_ScriptEngine_Evaluate(t *testing.T) { + + engine := &ScriptEngine{} + + type input struct { + root, current interface{} + expression string + options *option.QueryOptions + } + + type expected struct { + value interface{} + err string + } + + tests := []struct { + input input + expected expected + }{ + { + input: input{ + expression: "", + }, + expected: expected{ + err: "invalid expression. is empty", + }, + }, + { + input: input{ + root: "root", + current: "current", + expression: "nil", + }, + expected: expected{ + value: nil, + }, + }, + { + input: input{ + root: "root", + current: "current", + expression: "null", + }, + expected: expected{ + value: nil, + }, + }, + { + input: input{ + root: "root", + current: "current", + expression: "$", + }, + expected: expected{ + value: "'root'", + }, + }, + { + input: input{ + root: "root", + current: "current", + expression: "@", + }, + expected: expected{ + value: "'current'", + }, + }, + { + input: input{ + root: "root", + current: "current", + expression: "other", + }, + expected: expected{ + value: "other", + }, + }, + { + input: input{ + expression: "1+fish", + }, + expected: expected{ + err: "invalid argument. expected number", + }, + }, + { + input: input{ + expression: "@[-1]==2", + current: []interface{}{0, 2}, + }, + expected: expected{ + value: true, + }, + }, + { + input: input{ + expression: "@.name=~'hello.*'", + current: map[string]interface{}{ + "name": "hello world", + }, + }, + expected: expected{ + value: true, + }, + }, + { + input: input{ + expression: ".@.name=~'hello.*'", + current: map[string]interface{}{ + "name": "hello world", + }, + }, + expected: expected{ + err: "unexpected token '.' at index 0", + }, + }, + } + + for idx, test := range tests { + t.Run(fmt.Sprintf("%d", idx), func(t *testing.T) { + actual, err := engine.Evaluate(test.input.root, test.input.current, test.input.expression, test.input.options) + if test.expected.err == "" { + assert.Nil(t, err) + } else { + assert.EqualError(t, err, test.expected.err) + } + assert.Equal(t, test.expected.value, actual) + }) + } + +} + func Test_ScriptEngine_buildOperators(t *testing.T) { engine := &ScriptEngine{} @@ -437,6 +577,68 @@ func Test_ScriptEngine_buildOperators(t *testing.T) { }, }, }, + { + input: input{ + expression: "@.key=~'hello.*'", + tokens: defaultTokens, + }, + expected: expected{ + operator: ®exOperator{ + arg1: currentKey, + arg2: "'hello.*'", + }, + }, + }, + { + input: input{ + expression: ".$", + tokens: defaultTokens, + }, + expected: expected{ + operator: nil, + err: "unexpected token '.' at index 0", + }, + }, + { + input: input{ + expression: ".||", + tokens: []string{"||"}, + }, + expected: expected{ + operator: nil, + err: "", + }, + }, + { + input: input{ + expression: "||.", + tokens: []string{"||"}, + }, + expected: expected{ + operator: nil, + err: "", + }, + }, + { + input: input{ + expression: ".||.", + tokens: []string{"||"}, + }, + expected: expected{ + operator: &orOperator{arg1: ".", arg2: "."}, + err: "", + }, + }, + { + input: input{ + expression: "||.", + tokens: []string{"||", "&&"}, + }, + expected: expected{ + operator: nil, + err: "", + }, + }, } for idx, test := range tests { @@ -452,16 +654,15 @@ func Test_ScriptEngine_buildOperators(t *testing.T) { } } -func Test_ScriptEngine_Evaluate(t *testing.T) { +func Test_ScriptEngine_parseArgument(t *testing.T) { engine := &ScriptEngine{} type input struct { - root, current interface{} - expression string - options *option.QueryOptions + argument string + tokens []string + options *option.QueryOptions } - type expected struct { value interface{} err string @@ -473,84 +674,51 @@ func Test_ScriptEngine_Evaluate(t *testing.T) { }{ { input: input{ - expression: "", - }, - expected: expected{ - err: "invalid expression. is empty", - }, - }, - { - input: input{ - root: "root", - current: "current", - expression: "nil", - }, - expected: expected{ - value: nil, - }, - }, - { - input: input{ - root: "root", - current: "current", - expression: "null", - }, - expected: expected{ - value: nil, - }, - }, - { - input: input{ - root: "root", - current: "current", - expression: "$", - }, - expected: expected{ - value: "'root'", - }, - }, - { - input: input{ - root: "root", - current: "current", - expression: "@", + argument: ".$", + tokens: defaultTokens, }, expected: expected{ - value: "'current'", + err: "unexpected token '.' at index 0", }, }, { input: input{ - root: "root", - current: "current", - expression: "other", + argument: "1||1", + tokens: defaultTokens, }, expected: expected{ - value: "other", + value: &orOperator{arg1: "1", arg2: "1"}, }, }, { input: input{ - expression: "1+fish", + argument: "[1,2,3]", + tokens: defaultTokens, }, expected: expected{ - err: "invalid argument. expected number", + value: []interface{}{ + float64(1), + float64(2), + float64(3), + }, }, }, { input: input{ - expression: "@[-1]==2", - current: []interface{}{0, 2}, + argument: `{"key":"value"}`, + tokens: defaultTokens, }, expected: expected{ - value: true, + value: map[string]interface{}{ + "key": "value", + }, }, }, } for idx, test := range tests { t.Run(fmt.Sprintf("%d", idx), func(t *testing.T) { - actual, err := engine.Evaluate(test.input.root, test.input.current, test.input.expression, test.input.options) + actual, err := engine.parseArgument(test.input.argument, test.input.tokens, test.input.options) if test.expected.err == "" { assert.Nil(t, err) } else { @@ -559,5 +727,4 @@ func Test_ScriptEngine_Evaluate(t *testing.T) { assert.Equal(t, test.expected.value, actual) }) } - } diff --git a/script/standard/errors.go b/script/standard/errors.go index 2dbea64..a10cfed 100644 --- a/script/standard/errors.go +++ b/script/standard/errors.go @@ -1,6 +1,10 @@ package standard -import "fmt" +import ( + "fmt" + + "github.com/evilmonkeyinc/jsonpath/errors" +) var ( errUnsupportedOperator error = fmt.Errorf("unsupported operator") @@ -11,3 +15,7 @@ var ( errInvalidArgumentExpectedBoolean error = fmt.Errorf("%w. expected boolean", errInvalidArgument) errInvalidArgumentExpectedRegex error = fmt.Errorf("%w. expected a valid regexp", errInvalidArgument) ) + +func getInvalidExpressionEmptyError() error { + return fmt.Errorf("%w. is empty", errors.ErrInvalidExpression) +} diff --git a/script/standard/errors_test.go b/script/standard/errors_test.go new file mode 100644 index 0000000..dc488ad --- /dev/null +++ b/script/standard/errors_test.go @@ -0,0 +1,18 @@ +package standard + +import ( + goErr "errors" + "testing" + + "github.com/evilmonkeyinc/jsonpath/errors" + "github.com/stretchr/testify/assert" +) + +func Test_error(t *testing.T) { + + t.Run("getInvalidExpressionEmptyError", func(t *testing.T) { + actual := getInvalidExpressionEmptyError() + assert.EqualError(t, actual, "invalid expression. is empty") + assert.True(t, goErr.Is(actual, errors.ErrInvalidExpression)) + }) +} diff --git a/script/standard/expression.go b/script/standard/expression.go new file mode 100644 index 0000000..71198c9 --- /dev/null +++ b/script/standard/expression.go @@ -0,0 +1,44 @@ +package standard + +import "github.com/evilmonkeyinc/jsonpath/option" + +type compiledExpression struct { + expression string + rootOperator operator + engine *ScriptEngine + options *option.QueryOptions +} + +func (compiled *compiledExpression) Evaluate(root, current interface{}) (interface{}, error) { + expression := compiled.expression + if expression == "" { + return nil, getInvalidExpressionEmptyError() + } + parameters := map[string]interface{}{ + "$": root, + "@": current, + "nil": nil, + "null": nil, + } + + if compiled.rootOperator == nil { + if val, ok := parameters[expression]; ok { + return val, nil + } + + if number, err := getNumber(expression, parameters); err == nil { + return number, nil + } else if boolean, err := getBoolean(expression, parameters); err == nil { + return boolean, nil + } + + return expression, nil + } + + value, err := compiled.rootOperator.Evaluate(parameters) + if err != nil { + return nil, err + } + + return value, nil +} diff --git a/script/standard/expression_test.go b/script/standard/expression_test.go new file mode 100644 index 0000000..d98bf6b --- /dev/null +++ b/script/standard/expression_test.go @@ -0,0 +1,160 @@ +package standard + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_compiledExpression_Evaluate(t *testing.T) { + + compile := func(engine *ScriptEngine, expression string) *compiledExpression { + generic, _ := engine.Compile(expression, nil) + specific, _ := generic.(*compiledExpression) + return specific + } + + engine := &ScriptEngine{} + rootExpression := compile(engine, "$") + currentExpression := compile(engine, "@") + currentKeyExpression := compile(engine, "@.key") + nilExpression := compile(engine, "nil") + nullExpression := compile(engine, "null") + + type input struct { + compiled *compiledExpression + root, current interface{} + } + type expected struct { + err string + value interface{} + } + + tests := []struct { + input + expected + }{ + { + input: input{ + compiled: &compiledExpression{ + expression: "", + engine: engine, + }, + }, + expected: expected{ + err: "invalid expression. is empty", + }, + }, + { + input: input{ + compiled: &compiledExpression{ + expression: "true", + engine: engine, + }, + }, + expected: expected{ + value: true, + }, + }, + { + input: input{ + compiled: &compiledExpression{ + expression: "false", + engine: engine, + }, + }, + expected: expected{ + value: false, + }, + }, + { + input: input{ + compiled: &compiledExpression{ + expression: "3.14", + engine: engine, + }, + }, + expected: expected{ + value: float64(3.14), + }, + }, + { + input: input{ + compiled: &compiledExpression{ + expression: "3", + engine: engine, + }, + }, + expected: expected{ + value: float64(3), + }, + }, + { + input: input{ + compiled: &compiledExpression{ + expression: "[]", + engine: engine, + }, + }, + expected: expected{ + value: "[]", + }, + }, + { + input: input{ + compiled: rootExpression, + root: 123, + }, + expected: expected{ + value: int(123), + }, + }, + { + input: input{ + compiled: currentExpression, + current: int64(321), + }, + expected: expected{ + value: int64(321), + }, + }, + { + input: input{ + compiled: nilExpression, + }, + expected: expected{ + value: nil, + }, + }, + { + input: input{ + compiled: nullExpression, + }, + expected: expected{ + value: nil, + }, + }, + { + input: input{ + compiled: currentKeyExpression, + current: map[string]interface{}{}, + }, + expected: expected{ + err: "key: invalid token key 'key' not found", + }, + }, + } + + for idx, test := range tests { + t.Run(fmt.Sprintf("%d", idx), func(t *testing.T) { + actual, err := test.input.compiled.Evaluate(test.root, test.current) + if test.expected.err == "" { + assert.Nil(t, err) + } else { + assert.EqualError(t, err, test.expected.err) + } + assert.Equal(t, test.expected.value, actual) + }) + } +} diff --git a/script/standard/helper.go b/script/standard/helper.go index b2cb5b0..b52cdc8 100644 --- a/script/standard/helper.go +++ b/script/standard/helper.go @@ -1,11 +1,5 @@ package standard -import ( - "fmt" - - "github.com/evilmonkeyinc/jsonpath/errors" -) - func findUnquotedOperators(source string, operator string) int { inSingleQuotes := false inDoubleQuotes := false @@ -15,10 +9,10 @@ func findUnquotedOperators(source string, operator string) int { roundBracketClose := 0 inSquareBrackets := func() bool { - return squareBracketClose != squareBracketOpen + return squareBracketOpen > squareBracketClose } inRoundBrackets := func() bool { - return roundBracketOpen != roundBracketClose + return roundBracketOpen > roundBracketClose } for idx, rne := range source { @@ -83,7 +77,3 @@ func findUnquotedOperators(source string, operator string) int { return -1 } - -func getInvalidExpressionEmptyError() error { - return fmt.Errorf("%w. is empty", errors.ErrInvalidExpression) -} diff --git a/script/standard/helper_test.go b/script/standard/helper_test.go index e190bbe..3a3b7eb 100644 --- a/script/standard/helper_test.go +++ b/script/standard/helper_test.go @@ -125,6 +125,20 @@ func Test_findUnquotedOperators(t *testing.T) { }, expected: 6, }, + { + input: input{ + source: `'[]']`, + subString: "]", + }, + expected: 4, + }, + { + input: input{ + source: `'[(<"||">)]'`, + subString: "||", + }, + expected: -1, + }, } for idx, test := range tests { diff --git a/script/standard/operators_test.go b/script/standard/operators_test.go index 579d97f..8b389fb 100644 --- a/script/standard/operators_test.go +++ b/script/standard/operators_test.go @@ -377,6 +377,28 @@ func Test_getString(t *testing.T) { value: "true", }, }, + { + input: input{ + argument: `@`, + parameters: map[string]interface{}{ + "@": "'value'", + }, + }, + expected: expected{ + value: "'value'", + }, + }, + { + input: input{ + argument: `@`, + parameters: map[string]interface{}{ + "@": `"value"`, + }, + }, + expected: expected{ + value: "'value'", + }, + }, } for idx, test := range tests { diff --git a/selector.go b/selector.go new file mode 100644 index 0000000..3220d48 --- /dev/null +++ b/selector.go @@ -0,0 +1,91 @@ +package jsonpath + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/evilmonkeyinc/jsonpath/option" + "github.com/evilmonkeyinc/jsonpath/script" + "github.com/evilmonkeyinc/jsonpath/token" +) + +// Selector represents a compiled JSONPath selector +// and exposes functions to query JSON data and objects. +type Selector struct { + Options *option.QueryOptions + engine script.Engine + tokens []token.Token + selector string +} + +// String returns the compiled selector string representation +func (query *Selector) String() string { + jsonPath := "" + for _, token := range query.tokens { + jsonPath += fmt.Sprintf("%s", token) + } + return jsonPath +} + +// Query will return the result of the JSONPath query applied against the specified JSON data. +func (query *Selector) Query(root interface{}) (interface{}, error) { + if len(query.tokens) == 0 { + return nil, getInvalidJSONPathSelector(query.selector) + } + + tokens := make([]token.Token, 0) + if len(query.tokens) > 1 { + tokens = query.tokens[1:] + } + + found, err := query.tokens[0].Apply(root, root, tokens) + if err != nil { + return nil, err + } + return found, nil +} + +// QueryString will return the result of the JSONPath query applied against the specified JSON data. +func (query *Selector) QueryString(jsonData string) (interface{}, error) { + jsonData = strings.TrimSpace(jsonData) + if jsonData == "" { + return nil, getInvalidJSONData(errDataIsUnexpectedTypeOrNil) + } + + var root interface{} + + if strings.HasPrefix(jsonData, "{") && strings.HasSuffix(jsonData, "}") { + // object + root = make(map[string]interface{}) + if err := json.Unmarshal([]byte(jsonData), &root); err != nil { + return nil, getInvalidJSONData(err) + } + } else if strings.HasPrefix(jsonData, "[") && strings.HasSuffix(jsonData, "]") { + // array + root = make([]interface{}, 0) + if err := json.Unmarshal([]byte(jsonData), &root); err != nil { + return nil, getInvalidJSONData(err) + } + } else if len(jsonData) > 2 && strings.HasPrefix(jsonData, "\"") && strings.HasPrefix(jsonData, "\"") { + // string + root = jsonData[1 : len(jsonData)-1] + } else if strings.ToLower(jsonData) == "true" { + // bool true + root = true + } else if strings.ToLower(jsonData) == "false" { + // bool false + root = false + } else if val, err := strconv.ParseInt(jsonData, 10, 64); err == nil { + // integer + root = val + } else if val, err := strconv.ParseFloat(jsonData, 64); err == nil { + // float + root = val + } else { + return nil, getInvalidJSONData(errDataIsUnexpectedTypeOrNil) + } + + return query.Query(root) +} diff --git a/selector_test.go b/selector_test.go new file mode 100644 index 0000000..a0428cd --- /dev/null +++ b/selector_test.go @@ -0,0 +1,384 @@ +package jsonpath + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_Selector_String(t *testing.T) { + + tests := []struct { + input string + expected string + }{ + { + input: "$.store.book[*].author", + expected: "$['store']['book'][*]['author']", + }, + { + input: "$..author", + expected: "$..['author']", + }, + { + input: "$.store.*", + expected: "$['store'][*]", + }, + { + input: "$.store..price", + expected: "$['store']..['price']", + }, + { + input: "$..book[2]", + expected: "$..['book'][2]", + }, + { + input: "$..book[(@.length-1)]", + expected: "$..['book'][(@.length-1)]", + }, + { + input: "$..book[-1:]", + expected: "$..['book'][-1:]", + }, + { + input: "$..book[0,1]", + expected: "$..['book'][0,1]", + }, + { + input: "$..book[:2]", + expected: "$..['book'][:2]", + }, + { + input: "$..book[?(@.isbn)]", + expected: "$..['book'][?(@.isbn)]", + }, + { + input: "$..book[?(@.price<10)]", + expected: "$..['book'][?(@.price<10)]", + }, + { + input: "$..*", + expected: "$..[*]", + }, + { + input: "$.store. book[0].author", + expected: "$['store']['book'][0]['author']", + }, + } + + for idx, test := range tests { + t.Run(fmt.Sprintf("%d", idx), func(t *testing.T) { + compiled, _ := Compile(test.input) + assert.Equal(t, test.expected, compiled.String()) + }) + } + +} + +func Test_Selector_QueryString(t *testing.T) { + + sampleQuery, _ := Compile("$.expensive") + altSampleQuery, _ := Compile("$..author") + lengthQuery, _ := Compile("$.length") + rootQuery, _ := Compile("$") + + type input struct { + selector *Selector + jsonData string + } + + type expected struct { + value interface{} + err string + } + + tests := []struct { + input input + expected expected + }{ + { + input: input{ + selector: sampleQuery, + }, + expected: expected{ + err: "invalid data. unexpected type or nil", + }, + }, + { + input: input{ + selector: rootQuery, + jsonData: "42", + }, + expected: expected{ + value: int64(42), + }, + }, + { + input: input{ + selector: rootQuery, + jsonData: "3.14", + }, + expected: expected{ + value: float64(3.14), + }, + }, + { + input: input{ + selector: rootQuery, + jsonData: "true", + }, + expected: expected{ + value: true, + }, + }, + { + input: input{ + selector: rootQuery, + jsonData: "false", + }, + expected: expected{ + value: false, + }, + }, + { + input: input{ + selector: rootQuery, + jsonData: "not a json string", + }, + expected: expected{ + err: "invalid data. unexpected type or nil", + }, + }, + { + input: input{ + selector: rootQuery, + jsonData: `"json string"`, + }, + expected: expected{ + value: "json string", + }, + }, + { + input: input{ + selector: &Selector{ + selector: "invalid", + }, + jsonData: "{}", + }, + expected: expected{ + err: "invalid JSONPath selector 'invalid'", + }, + }, + { + input: input{ + selector: sampleQuery, + jsonData: `{"key"}`, + }, + expected: expected{ + err: "invalid data. invalid character '}' after object key", + }, + }, + { + input: input{ + selector: sampleQuery, + jsonData: "{}", + }, + expected: expected{ + err: "key: invalid token key 'expensive' not found", + }, + }, + { + input: input{ + selector: sampleQuery, + jsonData: `{"expensive": "test"}`, + }, + expected: expected{ + value: "test", + }, + }, + { + input: input{ + selector: sampleQuery, + jsonData: sampleDataString, + }, + expected: expected{ + value: int64(10), + }, + }, + { + input: input{ + selector: altSampleQuery, + jsonData: sampleDataString, + }, + expected: expected{ + value: []interface{}{ + "Nigel Rees", + "Evelyn Waugh", + "Herman Melville", + "J. R. R. Tolkien", + }, + }, + }, + { + input: input{ + selector: lengthQuery, + jsonData: `[1,2,3]`, + }, + expected: expected{ + value: int64(3), + }, + }, + { + input: input{ + selector: lengthQuery, + jsonData: `[1,2,]`, + }, + expected: expected{ + err: "invalid data. invalid character ']' looking for beginning of value", + }, + }, + } + + for idx, test := range tests { + t.Run(fmt.Sprintf("%d", idx), func(t *testing.T) { + value, err := test.input.selector.QueryString(test.input.jsonData) + + if test.expected.err != "" { + assert.EqualError(t, err, test.expected.err) + } else { + assert.Nil(t, err) + } + + if expectArray, ok := test.expected.value.([]interface{}); ok { + assert.ElementsMatch(t, expectArray, value) + } else { + assert.EqualValues(t, test.expected.value, value) + } + }) + } +} + +func Test_Selector_Query(t *testing.T) { + + sampleSelector, _ := Compile("$.expensive") + altSampleSelector, _ := Compile("$..author") + + type input struct { + selector *Selector + jsonData interface{} + } + + type expected struct { + value interface{} + err string + } + + tests := []struct { + input input + expected expected + }{ + { + input: input{ + selector: sampleSelector, + jsonData: make(chan bool, 1), + }, + expected: expected{ + err: "key: invalid token target. expected [map] got [chan]", + }, + }, + { + input: input{ + selector: sampleSelector, + jsonData: "not something that can be marshaled", + }, + expected: expected{ + err: "key: invalid token target. expected [map] got [string]", + }, + }, + { + input: input{ + selector: sampleSelector, + jsonData: &storeData{}, + }, + expected: expected{ + err: "key: invalid token key 'expensive' not found", + }, + }, + { + input: input{ + selector: &Selector{ + selector: "invalid", + }, + jsonData: &sampleData{}, + }, + expected: expected{ + err: "invalid JSONPath selector 'invalid'", + }, + }, + { + input: input{ + selector: sampleSelector, + jsonData: &bookData{}, + }, + expected: expected{ + err: "key: invalid token key 'expensive' not found", + }, + }, + { + input: input{ + selector: sampleSelector, + jsonData: &sampleData{ + Expensive: 15, + }, + }, + expected: expected{ + value: float64(15), + }, + }, + { + input: input{ + selector: sampleSelector, + jsonData: sampleDataObject, + }, + expected: expected{ + value: float32(10), + }, + }, + { + input: input{ + selector: altSampleSelector, + jsonData: sampleDataObject, + }, + expected: expected{ + value: []interface{}{ + "Nigel Rees", + "Evelyn Waugh", + "Herman Melville", + "J. R. R. Tolkien", + }, + }, + }, + } + + for idx, test := range tests { + t.Run(fmt.Sprintf("%d", idx), func(t *testing.T) { + value, err := test.input.selector.Query(test.input.jsonData) + + if test.expected.err != "" { + assert.EqualError(t, err, test.expected.err) + } else { + assert.Nil(t, err) + } + + if expectArray, ok := test.expected.value.([]interface{}); ok { + assert.NotNil(t, value) + if value != nil { + assert.ElementsMatch(t, expectArray, value) + } + } else { + assert.EqualValues(t, test.expected.value, value) + } + }) + } +} diff --git a/test/filter_test.go b/test/filter_test.go index bc7f46d..34ad1d7 100644 --- a/test/filter_test.go +++ b/test/filter_test.go @@ -564,7 +564,7 @@ var filterTests []testData = []testData{ expectedError: "", }, { - selector: `$[?(@.name=~/hello.*/)]`, // TODO : need regex support + selector: `$[?(@.name=~/hello.*/)]`, // TODO : need better regex support data: `[ {"name": "hullo world"}, {"name": "hello world"}, {"name": "yes hello world"}, {"name": "HELLO WORLD"}, {"name": "good bye"} ]`, expected: []interface{}{}, consensus: consensusNone, From 83f4ce12fa4b283b70e099393d4426594f83b802 Mon Sep 17 00:00:00 2001 From: Scott McGowan Date: Wed, 26 Jan 2022 08:03:31 +0000 Subject: [PATCH 2/6] test: additional tests for tokens package --- token/filter.go | 2 +- token/filter_test.go | 174 +++++++++++++++++++++++++++++++++++++++++++ token/helper.go | 3 + token/helper_test.go | 25 +++++++ 4 files changed, 203 insertions(+), 1 deletion(-) diff --git a/token/filter.go b/token/filter.go index 4e6fbbe..319977c 100644 --- a/token/filter.go +++ b/token/filter.go @@ -52,7 +52,7 @@ func (token *filterToken) Apply(root, current interface{}, next []Token) (interf if len(strValue) > 1 { if strings.HasPrefix(strValue, "'") && strings.HasSuffix(strValue, "'") { strValue = strValue[1 : len(strValue)-1] - } else if strings.HasPrefix(strValue, "\"") && strings.HasSuffix(strValue, "\"") { + } else if strings.HasPrefix(strValue, `"`) && strings.HasSuffix(strValue, `"`) { strValue = strValue[1 : len(strValue)-1] } } diff --git a/token/filter_test.go b/token/filter_test.go index ff4c824..41aad54 100644 --- a/token/filter_test.go +++ b/token/filter_test.go @@ -39,6 +39,10 @@ func Test_FilterToken_Type(t *testing.T) { func Test_FilterToken_Apply(t *testing.T) { + getNilPointer := func() *filterToken { + return nil + } + tests := []*tokenTest{ { token: &filterToken{}, @@ -337,6 +341,176 @@ func Test_FilterToken_Apply(t *testing.T) { value: []interface{}{"one", "two", "three"}, }, }, + { + token: &filterToken{ + expression: "array", + engine: &testEngine{ + compiledExpression: &testCompiledExpression{ + response: [1]string{"one"}, + }, + }, + }, + input: input{ + current: []interface{}{1, 2, 3}, + }, + expected: expected{ + value: []interface{}{1, 2, 3}, + err: "", + }, + }, + { + token: &filterToken{ + expression: "slice", + engine: &testEngine{ + compiledExpression: &testCompiledExpression{ + response: []string{"one"}, + }, + }, + }, + input: input{ + current: []interface{}{1, 2, 3}, + }, + expected: expected{ + value: []interface{}{1, 2, 3}, + err: "", + }, + }, + { + token: &filterToken{ + expression: "empty array", + engine: &testEngine{ + compiledExpression: &testCompiledExpression{ + response: [0]string{}, + }, + }, + }, + input: input{ + current: []interface{}{1, 2, 3}, + }, + expected: expected{ + value: []interface{}{}, + err: "", + }, + }, + { + token: &filterToken{ + expression: "map", + engine: &testEngine{ + compiledExpression: &testCompiledExpression{ + response: map[string]interface{}{"key": "value"}, + }, + }, + }, + input: input{ + current: []interface{}{1, 2, 3}, + }, + expected: expected{ + value: []interface{}{1, 2, 3}, + err: "", + }, + }, + { + token: &filterToken{ + expression: "empty map", + engine: &testEngine{ + compiledExpression: &testCompiledExpression{ + response: map[string]interface{}{}, + }, + }, + }, + input: input{ + current: []interface{}{1, 2, 3}, + }, + expected: expected{ + value: []interface{}{}, + err: "", + }, + }, + { + token: &filterToken{ + expression: "nil pointer", + engine: &testEngine{ + compiledExpression: &testCompiledExpression{ + response: getNilPointer(), + }, + }, + }, + input: input{ + current: []interface{}{1, 2, 3}, + }, + expected: expected{ + value: []interface{}{}, + err: "", + }, + }, + { + token: &filterToken{ + expression: "single quotes empty", + engine: &testEngine{ + compiledExpression: &testCompiledExpression{ + response: "''", + }, + }, + }, + input: input{ + current: []interface{}{1, 2, 3}, + }, + expected: expected{ + value: []interface{}{}, + err: "", + }, + }, + { + token: &filterToken{ + expression: "single quotes not empty", + engine: &testEngine{ + compiledExpression: &testCompiledExpression{ + response: "' '", + }, + }, + }, + input: input{ + current: []interface{}{1, 2, 3}, + }, + expected: expected{ + value: []interface{}{1, 2, 3}, + err: "", + }, + }, + { + token: &filterToken{ + expression: "double quotes empty", + engine: &testEngine{ + compiledExpression: &testCompiledExpression{ + response: `""`, + }, + }, + }, + input: input{ + current: []interface{}{1, 2, 3}, + }, + expected: expected{ + value: []interface{}{}, + err: "", + }, + }, + { + token: &filterToken{ + expression: "double quotes not empty", + engine: &testEngine{ + compiledExpression: &testCompiledExpression{ + response: `" "`, + }, + }, + }, + input: input{ + current: []interface{}{1, 2, 3}, + }, + expected: expected{ + value: []interface{}{1, 2, 3}, + err: "", + }, + }, } batchTokenTests(t, tests) diff --git a/token/helper.go b/token/helper.go index 38be87b..d892dd2 100644 --- a/token/helper.go +++ b/token/helper.go @@ -82,6 +82,9 @@ func getTypeAndValue(obj interface{}) (reflect.Type, reflect.Value) { objVal := reflect.ValueOf(obj) if objType.Kind() == reflect.Ptr { + if objVal.IsNil() { + return nil, reflect.ValueOf(nil) + } objType = objType.Elem() objVal = objVal.Elem() } diff --git a/token/helper_test.go b/token/helper_test.go index 3db7b68..206a36c 100644 --- a/token/helper_test.go +++ b/token/helper_test.go @@ -91,6 +91,20 @@ func Test_isInteger(t *testing.T) { ok: true, }, }, + { + input: float64(100), + expected: expected{ + value: 100, + ok: true, + }, + }, + { + input: float64(3.14), + expected: expected{ + value: 0, + ok: false, + }, + }, } for idx, test := range tests { @@ -185,6 +199,10 @@ func Test_getStructFields(t *testing.T) { func Test_getTypeAndValue(t *testing.T) { + getNilPointer := func() *sampleStruct { + return nil + } + sampleString := "sample" type expected struct { @@ -231,6 +249,13 @@ func Test_getTypeAndValue(t *testing.T) { value: sampleStruct{}, }, }, + { + input: getNilPointer(), + expected: expected{ + kind: reflect.Invalid, + value: nil, + }, + }, } for idx, test := range tests { From 8dfd3d2cff4f81a335248328c9ed9d7b7b517913 Mon Sep 17 00:00:00 2001 From: Scott McGowan Date: Wed, 26 Jan 2022 08:14:20 +0000 Subject: [PATCH 3/6] chore: update wording in readme --- script/standard/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/script/standard/README.md b/script/standard/README.md index 366d513..52e9b3b 100644 --- a/script/standard/README.md +++ b/script/standard/README.md @@ -26,7 +26,7 @@ The standard script engine is a basic implementation of the script.Engine interf The regex operator will perform a regex match check using the left side argument as the input and the right as the regex pattern. -The right side pattern must be passed as a string, between single or double quotes, to ensure that no characters are mistaken for other operators +The right side pattern should be passed as a string, between single or double quotes, to ensure that no characters are mistaken for other operators. > the regex operation is handled by the standard [`regexp`](https://pkg.go.dev/regexp) golang library `Match` function. @@ -45,4 +45,4 @@ Using the root or current symbol allows to embed a JSONPath selector within an e The nil and null tokens can be used interchangeably to represent a nil value. -> remember that the @ character has different meaning in subscripts than it does in filters +> remember that the @ character has different meaning in subscripts than it does in filters. From fd6f27936b4f520bb16b4c2b420fd88fa0005916 Mon Sep 17 00:00:00 2001 From: Scott McGowan Date: Wed, 26 Jan 2022 19:26:47 +0000 Subject: [PATCH 4/6] feat: add not operator added operator to flip logic operator result --- script/standard/README.md | 6 + script/standard/engine.go | 96 +++++---- script/standard/engine_test.go | 249 +++++++++++++++++++++++- script/standard/logic_operators.go | 14 ++ script/standard/logic_operators_test.go | 86 ++++++++ 5 files changed, 410 insertions(+), 41 deletions(-) diff --git a/script/standard/README.md b/script/standard/README.md index 52e9b3b..6066be5 100644 --- a/script/standard/README.md +++ b/script/standard/README.md @@ -46,3 +46,9 @@ Using the root or current symbol allows to embed a JSONPath selector within an e The nil and null tokens can be used interchangeably to represent a nil value. > remember that the @ character has different meaning in subscripts than it does in filters. + +## Limitations + +The script parser does not infer meaning from symbols/tokens and the neighboring characters, what may be considered a valid mathematical equation is not always a valid script expression. + +For example, the equations `8+3-3*6+2(2*3)` will not be parsed correctly as the engine does not under stand that `2(2*3)` is the same as `2*(2*3)`, if you needed such an equation you would remove any ambiguity of what a number next to a bracket means like so `8+3-3*6+2*(2*3)`. diff --git a/script/standard/engine.go b/script/standard/engine.go index 65b902e..44be857 100644 --- a/script/standard/engine.go +++ b/script/standard/engine.go @@ -13,7 +13,7 @@ import ( // TODO : add support for bitwise operators | &^ ^ & << >> after + and - var defaultTokens []string = []string{ "||", "&&", - "==", "!=", "<=", ">=", "<", ">", "=~", + "==", "!=", "<=", ">=", "<", ">", "=~", "!", "+", "-", "**", "*", "/", "%", "@", "$", @@ -75,8 +75,8 @@ func (engine *ScriptEngine) buildOperators(expression string, tokens []string, o // check right for more tokens, or use raw string as input // right check needs done first as some expressions just have left sides - arg2Str := strings.TrimSpace(expression[idx+(len(nextToken)):]) - if arg2Str == "" && (nextToken != "$" && nextToken != "@") { + rightsideString := strings.TrimSpace(expression[idx+(len(nextToken)):]) + if rightsideString == "" && (nextToken != "$" && nextToken != "@") { if len(tokens) == 1 { return nil, nil } @@ -84,14 +84,14 @@ func (engine *ScriptEngine) buildOperators(expression string, tokens []string, o return engine.buildOperators(expression, tokens[1:], options) } - arg2, err := engine.parseArgument(arg2Str, tokens, options) + rightside, err := engine.parseArgument(rightsideString, tokens, options) if err != nil { return nil, err } // check left for more tokens, or use raw string as input - arg1Str := strings.TrimSpace(expression[0:idx]) - if arg1Str == "" && (nextToken != "$" && nextToken != "@") { + leftsideString := strings.TrimSpace(expression[0:idx]) + if leftsideString == "" && (nextToken != "$" && nextToken != "@" && nextToken != "!") { if len(tokens) == 1 { return nil, nil } @@ -99,13 +99,20 @@ func (engine *ScriptEngine) buildOperators(expression string, tokens []string, o return engine.buildOperators(expression, tokens[1:], options) } - arg1, err := engine.parseArgument(arg1Str, tokens, options) + leftside, err := engine.parseArgument(leftsideString, tokens, options) if err != nil { return nil, err } switch nextToken { case "@", "$": + if leftside != nil { + // There should not be a left side to this operator + if len(tokens) == 1 { + return nil, nil + } + return engine.buildOperators(expression, tokens[1:], options) + } selector, err := newSelectorOperator(expression, engine, options) if err != nil { return nil, err @@ -113,78 +120,89 @@ func (engine *ScriptEngine) buildOperators(expression string, tokens []string, o return selector, nil case "=~": return ®exOperator{ - arg1: arg1, - arg2: arg2, + arg1: leftside, + arg2: rightside, + }, nil + case "!": + if leftside != nil { + // There should not be a left side to this operator + if len(tokens) == 1 { + return nil, nil + } + return engine.buildOperators(expression, tokens[1:], options) + } + return ¬Operator{ + arg: rightside, }, nil case "||": return &orOperator{ - arg1: arg1, - arg2: arg2, + arg1: leftside, + arg2: rightside, }, nil case "&&": return &andOperator{ - arg1: arg1, - arg2: arg2, + arg1: leftside, + arg2: rightside, }, nil case "==": return &equalsOperator{ - arg1: arg1, - arg2: arg2, + arg1: leftside, + arg2: rightside, }, nil case "!=": return ¬EqualsOperator{ - arg1: arg1, - arg2: arg2, + arg1: leftside, + arg2: rightside, }, nil case "<=": return &lessThanOrEqualOperator{ - arg1: arg1, - arg2: arg2, + arg1: leftside, + arg2: rightside, }, nil case ">=": return &greaterThanOrEqualOperator{ - arg1: arg1, - arg2: arg2, + arg1: leftside, + arg2: rightside, }, nil case "<": return &lessThanOperator{ - arg1: arg1, - arg2: arg2, + arg1: leftside, + arg2: rightside, }, nil case ">": return &greaterThanOperator{ - arg1: arg1, - arg2: arg2, + arg1: leftside, + arg2: rightside, }, nil case "+": return &plusOperator{ - arg1: arg1, - arg2: arg2, + arg1: leftside, + arg2: rightside, }, nil case "-": return &subtractOperator{ - arg1: arg1, - arg2: arg2, + arg1: leftside, + arg2: rightside, }, nil case "**": return &powerOfOperator{ - arg1: arg1, - arg2: arg2, + arg1: leftside, + arg2: rightside, }, nil case "*": return &multiplyOperator{ - arg1: arg1, - arg2: arg2, + arg1: leftside, + arg2: rightside, }, nil case "/": return ÷Operator{ - arg1: arg1, - arg2: arg2, + arg1: leftside, + arg2: rightside, }, nil case "%": return &modulusOperator{ - arg1: arg1, - arg2: arg2, + arg1: leftside, + arg2: rightside, }, nil } @@ -201,6 +219,10 @@ func (engine *ScriptEngine) parseArgument(argument string, tokens []string, opti } argument = strings.TrimSpace(argument) + if argument == "" { + return nil, nil + } + var arg interface{} = argument if strings.HasPrefix(argument, "[") && strings.HasSuffix(argument, "]") { val := make([]interface{}, 0) diff --git a/script/standard/engine_test.go b/script/standard/engine_test.go index 268f485..9d8af40 100644 --- a/script/standard/engine_test.go +++ b/script/standard/engine_test.go @@ -67,7 +67,21 @@ func Test_ScriptEngine_Compile(t *testing.T) { expression: ".$", }, expected: expected{ - err: "unexpected token '.' at index 0", + compiled: &compiledExpression{ + expression: ".$", + rootOperator: nil, + engine: engine, + options: nil, + }, + err: "", + }, + }, + { + input: input{ + expression: "$[]", + }, + expected: expected{ + err: "invalid token. '[]' does not match any token format", }, }, } @@ -199,7 +213,18 @@ func Test_ScriptEngine_Evaluate(t *testing.T) { }, }, expected: expected{ - err: "unexpected token '.' at index 0", + value: false, + }, + }, + { + input: input{ + expression: "@[]=~'hello.*'", + current: map[string]interface{}{ + "name": "hello world", + }, + }, + expected: expected{ + err: "invalid token. '[]' does not match any token format", }, }, } @@ -596,7 +621,17 @@ func Test_ScriptEngine_buildOperators(t *testing.T) { }, expected: expected{ operator: nil, - err: "unexpected token '.' at index 0", + err: "", + }, + }, + { + input: input{ + expression: "$[]", + tokens: defaultTokens, + }, + expected: expected{ + operator: nil, + err: "invalid token. '[]' does not match any token format", }, }, { @@ -639,6 +674,48 @@ func Test_ScriptEngine_buildOperators(t *testing.T) { err: "", }, }, + { + input: input{ + expression: "!true", + tokens: defaultTokens, + }, + expected: expected{ + operator: ¬Operator{arg: "true"}, + err: "", + }, + }, + { + input: input{ + expression: "!(0==1)", + tokens: defaultTokens, + }, + expected: expected{ + operator: ¬Operator{ + arg: &equalsOperator{arg1: "0", arg2: "1"}, + }, + err: "", + }, + }, + { + input: input{ + expression: "true!true", + tokens: defaultTokens, + }, + expected: expected{ + operator: nil, + err: "", + }, + }, + { + input: input{ + expression: "true!true", + tokens: []string{"!"}, + }, + expected: expected{ + operator: nil, + err: "", + }, + }, } for idx, test := range tests { @@ -678,7 +755,16 @@ func Test_ScriptEngine_parseArgument(t *testing.T) { tokens: defaultTokens, }, expected: expected{ - err: "unexpected token '.' at index 0", + value: ".$", + }, + }, + { + input: input{ + argument: "$[]", + tokens: defaultTokens, + }, + expected: expected{ + err: "invalid token. '[]' does not match any token format", }, }, { @@ -728,3 +814,158 @@ func Test_ScriptEngine_parseArgument(t *testing.T) { }) } } + +func Test_Evaluate(t *testing.T) { + + tests := []struct { + expression string + expected interface{} + }{ + { + expression: "true || false", + expected: true, + }, + { + expression: "true && true", + expected: true, + }, + { + expression: "false || true && true", + expected: true, + }, + { + expression: "true && true || false", + expected: true, + }, + { + expression: "true == true", + expected: true, + }, + { + expression: "'true' == true", + expected: false, + }, + { + expression: "'true' == 'true'", + expected: true, + }, + { + expression: "true != true", + expected: false, + }, + { + expression: "'true' != true", + expected: true, + }, + { + expression: "'true' != 'true'", + expected: false, + }, + { + expression: "1 < 2", + expected: true, + }, + { + expression: "2 < 2", + expected: false, + }, + { + expression: "1 <= 2", + expected: true, + }, + { + expression: "2 <= 2", + expected: true, + }, + { + expression: "2 > 1", + expected: true, + }, + { + expression: "2 > 2", + expected: false, + }, + { + expression: "2 >= 1", + expected: true, + }, + { + expression: "2 >= 2", + expected: true, + }, + { + expression: "1 + 2", + expected: float64(3), + }, + { + expression: "2 - 1", + expected: float64(1), + }, + { + expression: "1 + 2 - 3", + expected: float64(0), + }, + { + expression: "1 * 2", + expected: float64(2), + }, + { + expression: "1 / 2", + expected: float64(0.5), + }, + { + expression: "1 * 2 / 8", + expected: float64(0.25), + }, + { + expression: "2 ** 0", + expected: float64(1), + }, + { + expression: "2 ** 1", + expected: float64(2), + }, + { + expression: "2 ** 2", + expected: float64(4), + }, + { + expression: "4 % 2", + expected: int64(0), + }, + { + expression: "4 % 3", + expected: int64(1), + }, + { + expression: "15/5*(8-6+3)*5", + expected: float64(75), + }, + { + expression: "8+3-3*6+2*(2*3)", + expected: float64(5), + }, + { + expression: "!false", + expected: true, + }, + { + expression: "!true", + expected: false, + }, + { + expression: "!(1==1)", + expected: false, + }, + } + + for idx, test := range tests { + t.Run(fmt.Sprintf("%d", idx), func(t *testing.T) { + engine := &ScriptEngine{} + actual, err := engine.Evaluate(nil, nil, test.expression, nil) + assert.Nil(t, err) + assert.Equal(t, test.expected, actual) + }) + } + +} diff --git a/script/standard/logic_operators.go b/script/standard/logic_operators.go index e9f5e20..b1fa419 100644 --- a/script/standard/logic_operators.go +++ b/script/standard/logic_operators.go @@ -157,3 +157,17 @@ func (op *notEqualsOperator) Evaluate(parameters map[string]interface{}) (interf return first != second, nil } + +type notOperator struct { + arg interface{} +} + +func (op *notOperator) Evaluate(parameters map[string]interface{}) (interface{}, error) { + + result, err := getBoolean(op.arg, parameters) + if err != nil { + return nil, err + } + + return !result, nil +} diff --git a/script/standard/logic_operators_test.go b/script/standard/logic_operators_test.go index b803dbe..588b0a3 100644 --- a/script/standard/logic_operators_test.go +++ b/script/standard/logic_operators_test.go @@ -512,3 +512,89 @@ func Test_notEqualsOperator(t *testing.T) { } batchOperatorTests(t, tests) } + +func Test_notOperator(t *testing.T) { + tests := []*operatorTest{ + { + input: operatorTestInput{ + operator: ¬Operator{ + arg: nil, + }, + }, + expected: operatorTestExpected{ + err: "invalid argument. is nil", + }, + }, + { + input: operatorTestInput{ + operator: ¬Operator{ + arg: "value", + }, + }, + expected: operatorTestExpected{ + err: "invalid argument. expected boolean", + }, + }, + { + input: operatorTestInput{ + operator: ¬Operator{ + arg: "1", + }, + }, + expected: operatorTestExpected{ + value: false, + }, + }, + { + input: operatorTestInput{ + operator: ¬Operator{ + arg: 2, + }, + }, + expected: operatorTestExpected{ + err: "invalid argument. expected boolean", + }, + }, + { + input: operatorTestInput{ + operator: ¬Operator{ + arg: true, + }, + }, + expected: operatorTestExpected{ + value: false, + }, + }, + { + input: operatorTestInput{ + operator: ¬Operator{ + arg: false, + }, + }, + expected: operatorTestExpected{ + value: true, + }, + }, + { + input: operatorTestInput{ + operator: ¬Operator{ + arg: &equalsOperator{arg1: "1", arg2: "1"}, + }, + }, + expected: operatorTestExpected{ + value: false, + }, + }, + { + input: operatorTestInput{ + operator: ¬Operator{ + arg: &equalsOperator{arg1: "2", arg2: "1"}, + }, + }, + expected: operatorTestExpected{ + value: true, + }, + }, + } + batchOperatorTests(t, tests) +} From 1314c4b5e57f510ada6e6eb9fe93d7127240f319 Mon Sep 17 00:00:00 2001 From: Scott McGowan Date: Wed, 26 Jan 2022 19:51:04 +0000 Subject: [PATCH 5/6] fix: allow nil to be considered false updated getBoolean to return false with nil arguments updated consensus tests --- script/standard/logic_operators_test.go | 2 +- script/standard/operators.go | 6 +++- script/standard/operators_test.go | 23 ++++++++++++- test/README.md | 12 +++---- test/filter_test.go | 46 +++++++++++++------------ test/union_test.go | 4 +-- 6 files changed, 60 insertions(+), 33 deletions(-) diff --git a/script/standard/logic_operators_test.go b/script/standard/logic_operators_test.go index 588b0a3..9f298d6 100644 --- a/script/standard/logic_operators_test.go +++ b/script/standard/logic_operators_test.go @@ -522,7 +522,7 @@ func Test_notOperator(t *testing.T) { }, }, expected: operatorTestExpected{ - err: "invalid argument. is nil", + value: true, }, }, { diff --git a/script/standard/operators.go b/script/standard/operators.go index 2b7300c..3e38cc8 100644 --- a/script/standard/operators.go +++ b/script/standard/operators.go @@ -73,7 +73,7 @@ func getNumber(argument interface{}, parameters map[string]interface{}) (float64 func getBoolean(argument interface{}, parameters map[string]interface{}) (bool, error) { if argument == nil { - return false, errInvalidArgumentNil + return false, nil } if parameters == nil { parameters = make(map[string]interface{}) @@ -93,6 +93,10 @@ func getBoolean(argument interface{}, parameters map[string]interface{}) (bool, } } + if argument == nil { + return false, nil + } + str := fmt.Sprintf("%v", argument) boolValue, err := strconv.ParseBool(str) if err != nil { diff --git a/script/standard/operators_test.go b/script/standard/operators_test.go index 8b389fb..186f811 100644 --- a/script/standard/operators_test.go +++ b/script/standard/operators_test.go @@ -213,6 +213,8 @@ func Test_getNumber(t *testing.T) { func Test_getBoolean(t *testing.T) { + currentSelector, _ := newSelectorOperator("@", &ScriptEngine{}, nil) + type input struct { argument interface{} parameters map[string]interface{} @@ -230,7 +232,7 @@ func Test_getBoolean(t *testing.T) { { input: input{}, expected: expected{ - err: "invalid argument. is nil", + value: false, }, }, { @@ -287,6 +289,25 @@ func Test_getBoolean(t *testing.T) { value: true, }, }, + { + input: input{ + argument: currentSelector, + }, + expected: expected{ + value: false, + }, + }, + { + input: input{ + argument: "null", + parameters: map[string]interface{}{ + "null": nil, + }, + }, + expected: expected{ + value: false, + }, + }, } for idx, test := range tests { diff --git a/test/README.md b/test/README.md index 76207af..14846f9 100644 --- a/test/README.md +++ b/test/README.md @@ -217,17 +217,17 @@ This implementation would be closer to the 'Scalar consensus' as it does not alw |:question:|`$[?(@.key>42)]`|`[ {"key": 0}, {"key": 42}, {"key": -1}, {"key": 41}, {"key": 43}, {"key": 42.0001}, {"key": 41.9999}, {"key": 100}, {"key": "43"}, {"key": "42"}, {"key": "41"}, {"key": "value"}, {"some": "value"} ]`|none|`[{"key":43},{"key":42.0001},{"key":100}]`| |:question:|`$[?(@.key>=42)]`|`[ {"key": 0}, {"key": 42}, {"key": -1}, {"key": 41}, {"key": 43}, {"key": 42.0001}, {"key": 41.9999}, {"key": 100}, {"key": "43"}, {"key": "42"}, {"key": "41"}, {"key": "value"}, {"some": "value"} ]`|none|`[{"key":42},{"key":43},{"key":42.0001},{"key":100}]`| |:no_entry:|`$[?(@.d in [2, 3])]`|`[{"d": 1}, {"d": 2}, {"d": 1}, {"d": 3}, {"d": 4}]`|`nil`|`[]`| -|:white_check_mark:|`$[?(2 in @.d)]`|`[{"d": [1, 2, 3]}, {"d": [2]}, {"d": [1]}, {"d": [3, 4]}, {"d": [4, 2]}]`|`nil`|`null`| +|:no_entry:|`$[?(2 in @.d)]`|`[{"d": [1, 2, 3]}, {"d": [2]}, {"d": [1]}, {"d": [3, 4]}, {"d": [4, 2]}]`|`nil`|`[{"d":[1,2,3]},{"d":[2]},{"d":[1]},{"d":[3,4]},{"d":[4,2]}]`| |:question:|`$[?(@.key<42)]`|`[ {"key": 0}, {"key": 42}, {"key": -1}, {"key": 41}, {"key": 43}, {"key": 42.0001}, {"key": 41.9999}, {"key": 100}, {"key": "43"}, {"key": "42"}, {"key": "41"}, {"key": "value"}, {"some": "value"} ]`|none|`[{"key":0},{"key":-1},{"key":41},{"key":41.9999}]`| |:question:|`$[?(@.key<=42)]`|`[ {"key": 0}, {"key": 42}, {"key": -1}, {"key": 41}, {"key": 43}, {"key": 42.0001}, {"key": 41.9999}, {"key": 100}, {"key": "43"}, {"key": "42"}, {"key": "41"}, {"key": "value"}, {"some": "value"} ]`|none|`[{"key":0},{"key":42},{"key":-1},{"key":41},{"key":41.9999}]`| |:question:|`$[?(@.key*2==100)]`|`[{"key": 60}, {"key": 50}, {"key": 10}, {"key": -50}, {"key*2": 100}]`|none|`[{"key":50}]`| -|:question:|`$[?(!(@.key==42))]`|`[ {"key": 0}, {"key": 42}, {"key": -1}, {"key": 41}, {"key": 43}, {"key": 42.0001}, {"key": 41.9999}, {"key": 100}, {"key": "43"}, {"key": "42"}, {"key": "41"}, {"key": "value"}, {"some": "value"} ]`|none|`[{"key":0},{"key":42},{"key":-1},{"key":41},{"key":43},{"key":42.0001},{"key":41.9999},{"key":100},{"key":"43"},{"key":"42"},{"key":"41"},{"key":"value"},{"some":"value"}]`| -|:question:|`$[?(!(@.key<42))]`|`[ {"key": 0}, {"key": 42}, {"key": -1}, {"key": 41}, {"key": 43}, {"key": 42.0001}, {"key": 41.9999}, {"key": 100}, {"key": "43"}, {"key": "42"}, {"key": "41"}, {"key": "value"}, {"some": "value"} ]`|none|`[{"key":0},{"key":42},{"key":-1},{"key":41},{"key":43},{"key":42.0001},{"key":41.9999},{"key":100},{"key":"43"},{"key":"42"},{"key":"41"},{"key":"value"},{"some":"value"}]`| -|:question:|`$[?(!@.key)]`|`[ { "some": "some value" }, { "key": true }, { "key": false }, { "key": null }, { "key": "value" }, { "key": "" }, { "key": 0 }, { "key": 1 }, { "key": -1 }, { "key": 42 }, { "key": {} }, { "key": [] } ]`|none|`null`| +|:question:|`$[?(!(@.key==42))]`|`[ {"key": 0}, {"key": 42}, {"key": -1}, {"key": 41}, {"key": 43}, {"key": 42.0001}, {"key": 41.9999}, {"key": 100}, {"key": "43"}, {"key": "42"}, {"key": "41"}, {"key": "value"}, {"some": "value"} ]`|none|`[{"key":0},{"key":-1},{"key":41},{"key":43},{"key":42.0001},{"key":41.9999},{"key":100},{"key":"43"},{"key":"42"},{"key":"41"},{"key":"value"}]`| +|:question:|`$[?(!(@.key<42))]`|`[ {"key": 0}, {"key": 42}, {"key": -1}, {"key": 41}, {"key": 43}, {"key": 42.0001}, {"key": 41.9999}, {"key": 100}, {"key": "43"}, {"key": "42"}, {"key": "41"}, {"key": "value"}, {"some": "value"} ]`|none|`[{"key":42},{"key":43},{"key":42.0001},{"key":100}]`| +|:question:|`$[?(!@.key)]`|`[ { "some": "some value" }, { "key": true }, { "key": false }, { "key": null }, { "key": "value" }, { "key": "" }, { "key": 0 }, { "key": 1 }, { "key": -1 }, { "key": 42 }, { "key": {} }, { "key": [] } ]`|none|`[{"key":false},{"key":null},{"key":0}]`| |:question:|`$[?(@.key!=42)]`|`[ {"key": 0}, {"key": 42}, {"key": -1}, {"key": 1}, {"key": 41}, {"key": 43}, {"key": 42.0001}, {"key": 41.9999}, {"key": 100}, {"key": "some"}, {"key": "42"}, {"key": null}, {"key": 420}, {"key": ""}, {"key": {}}, {"key": []}, {"key": [42]}, {"key": {"key": 42}}, {"key": {"some": 42}}, {"some": "value"} ]`|none|`[{"key":0},{"key":-1},{"key":1},{"key":41},{"key":43},{"key":42.0001},{"key":41.9999},{"key":100},{"key":"some"},{"key":"42"},{"key":null},{"key":420},{"key":""},{"key":{}},{"key":[]},{"key":[42]},{"key":{"key":42}},{"key":{"some":42}}]`| |:no_entry:|`$[*].bookmarks[?(@.page == 45)]^^^`|`[ { "title": "Sayings of the Century", "bookmarks": [{ "page": 40 }] }, { "title": "Sword of Honour", "bookmarks": [ { "page": 35 }, { "page": 45 } ] }, { "title": "Moby Dick", "bookmarks": [ { "page": 3035 }, { "page": 45 } ] } ]`|`nil`|`[[],[],[]]`| |:question:|`$[?(@.name=~/hello.*/)]`|`[ {"name": "hullo world"}, {"name": "hello world"}, {"name": "yes hello world"}, {"name": "HELLO WORLD"}, {"name": "good bye"} ]`|none|`[]`| -|:question:|`$[?(@.name=~/@.pattern/)]`|`[ {"name": "hullo world"}, {"name": "hello world"}, {"name": "yes hello world"}, {"name": "HELLO WORLD"}, {"name": "good bye"}, {"pattern": "hello.*"} ]`|none|`null`| +|:question:|`$[?(@.name=~/@.pattern/)]`|`[ {"name": "hullo world"}, {"name": "hello world"}, {"name": "yes hello world"}, {"name": "HELLO WORLD"}, {"name": "good bye"}, {"pattern": "hello.*"} ]`|none|`[]`| |:question:|`$[?(@[*]>=4)]`|`[[1,2],[3,4],[5,6]]`|none|`[]`| |:question:|`$.x[?(@[*]>=$.y[*])]`|`{"x":[[1,2],[3,4],[5,6]],"y":[3,4,5]}`|none|`[]`| |:no_entry:|`$[?(@.key=42)]`|`[ {"key": 0}, {"key": 42}, {"key": -1}, {"key": 1}, {"key": 41}, {"key": 43}, {"key": 42.0001}, {"key": 41.9999}, {"key": 100}, {"key": "some"}, {"key": "42"}, {"key": null}, {"key": 420}, {"key": ""}, {"key": {}}, {"key": []}, {"key": [42]}, {"key": {"key": 42}}, {"key": {"some": 42}}, {"some": "value"} ]`|`nil`|`[]`| @@ -268,7 +268,7 @@ This implementation would be closer to the 'Scalar consensus' as it does not alw |:white_check_mark:|`$[0,1]`|`["first", "second", "third"]`|`["first","second"]`|`["first","second"]`| |:white_check_mark:|`$[0,0]`|`["a"]`|`["a","a"]`|`["a","a"]`| |:white_check_mark:|`$['a','a']`|`{"a":1}`|`[1,1]`|`[1,1]`| -|:question:|`$[?(@.key<3),?(@.key>6)]`|`[{"key": 1}, {"key": 8}, {"key": 3}, {"key": 10}, {"key": 7}, {"key": 2}, {"key": 6}, {"key": 4}]`|none|`null`| +|:question:|`$[?(@.key<3),?(@.key>6)]`|`[{"key": 1}, {"key": 8}, {"key": 3}, {"key": 10}, {"key": 7}, {"key": 2}, {"key": 6}, {"key": 4}]`|none|`[]`| |:white_check_mark:|`$['key','another']`|`{ "key": "value", "another": "entry" }`|`["value","entry"]`|`["value","entry"]`| |:white_check_mark:|`$['missing','key']`|`{ "key": "value", "another": "entry" }`|`["value"]`|`["value"]`| |:no_entry:|`$[:]['c','d']`|`[{"c":"cc1","d":"dd1","e":"ee1"},{"c":"cc2","d":"dd2","e":"ee2"}]`|`["cc1","dd1","cc2","dd2"]`|`[["cc1","dd1"],["cc2","dd2"]]`| diff --git a/test/filter_test.go b/test/filter_test.go index 34ad1d7..66c9023 100644 --- a/test/filter_test.go +++ b/test/filter_test.go @@ -443,11 +443,17 @@ var filterTests []testData = []testData{ expectedError: "", }, { - selector: `$[?(2 in @.d)]`, // TODO : in keywork will not work - data: `[{"d": [1, 2, 3]}, {"d": [2]}, {"d": [1]}, {"d": [3, 4]}, {"d": [4, 2]}]`, - expected: nil, + selector: `$[?(2 in @.d)]`, // TODO : in keywork will not work + data: `[{"d": [1, 2, 3]}, {"d": [2]}, {"d": [1]}, {"d": [3, 4]}, {"d": [4, 2]}]`, + expected: []interface{}{ + map[string]interface{}{"d": []interface{}{float64(1), float64(2), float64(3)}}, + map[string]interface{}{"d": []interface{}{float64(2)}}, + map[string]interface{}{"d": []interface{}{float64(1)}}, + map[string]interface{}{"d": []interface{}{float64(3), float64(4)}}, + map[string]interface{}{"d": []interface{}{float64(4), float64(2)}}, + }, consensus: nil, - expectedError: "invalid expression. unexpected token '2' at index 0", + expectedError: "", }, { selector: `$[?(@.key<42)]`, @@ -486,7 +492,6 @@ var filterTests []testData = []testData{ data: `[ {"key": 0}, {"key": 42}, {"key": -1}, {"key": 41}, {"key": 43}, {"key": 42.0001}, {"key": 41.9999}, {"key": 100}, {"key": "43"}, {"key": "42"}, {"key": "41"}, {"key": "value"}, {"some": "value"} ]`, expected: []interface{}{ map[string]interface{}{"key": float64(0)}, - map[string]interface{}{"key": float64(42)}, map[string]interface{}{"key": float64(-1)}, map[string]interface{}{"key": float64(41)}, map[string]interface{}{"key": float64(43)}, @@ -497,38 +502,35 @@ var filterTests []testData = []testData{ map[string]interface{}{"key": "42"}, map[string]interface{}{"key": "41"}, map[string]interface{}{"key": "value"}, - map[string]interface{}{"some": "value"}, + // map[string]interface{}{"some": "value"}, // TODO : it should include this }, consensus: consensusNone, expectedError: "", }, { + // TODO : should include those without key fields selector: `$[?(!(@.key<42))]`, data: `[ {"key": 0}, {"key": 42}, {"key": -1}, {"key": 41}, {"key": 43}, {"key": 42.0001}, {"key": 41.9999}, {"key": 100}, {"key": "43"}, {"key": "42"}, {"key": "41"}, {"key": "value"}, {"some": "value"} ]`, - expected: []interface{}{ // TODO : wrong - map[string]interface{}{"key": float64(0)}, + expected: []interface{}{ map[string]interface{}{"key": float64(42)}, - map[string]interface{}{"key": float64(-1)}, - map[string]interface{}{"key": float64(41)}, map[string]interface{}{"key": float64(43)}, map[string]interface{}{"key": float64(42.0001)}, - map[string]interface{}{"key": float64(41.9999)}, map[string]interface{}{"key": float64(100)}, - map[string]interface{}{"key": "43"}, - map[string]interface{}{"key": "42"}, - map[string]interface{}{"key": "41"}, - map[string]interface{}{"key": "value"}, - map[string]interface{}{"some": "value"}, }, consensus: consensusNone, expectedError: "", }, { - selector: `$[?(!@.key)]`, // TODO : not ! support - data: `[ { "some": "some value" }, { "key": true }, { "key": false }, { "key": null }, { "key": "value" }, { "key": "" }, { "key": 0 }, { "key": 1 }, { "key": -1 }, { "key": 42 }, { "key": {} }, { "key": [] } ]`, - expected: nil, + // TODO : this should also include {some: some value} and the null and empty + selector: `$[?(!@.key)]`, + data: `[ { "some": "some value" }, { "key": true }, { "key": false }, { "key": null }, { "key": "value" }, { "key": "" }, { "key": 0 }, { "key": 1 }, { "key": -1 }, { "key": 42 }, { "key": {} }, { "key": [] } ]`, + expected: []interface{}{ + map[string]interface{}{"key": false}, + map[string]interface{}{"key": nil}, + map[string]interface{}{"key": float64(0)}, + }, consensus: consensusNone, - expectedError: "invalid expression. unexpected token '!' at index 0", + expectedError: "", }, { selector: `$[?(@.key!=42)]`, @@ -573,9 +575,9 @@ var filterTests []testData = []testData{ { selector: `$[?(@.name=~/@.pattern/)]`, data: `[ {"name": "hullo world"}, {"name": "hello world"}, {"name": "yes hello world"}, {"name": "HELLO WORLD"}, {"name": "good bye"}, {"pattern": "hello.*"} ]`, - expected: nil, + expected: []interface{}{}, consensus: consensusNone, - expectedError: "invalid expression. unexpected token '/' at index 0", + expectedError: "", }, { selector: `$[?(@[*]>=4)]`, diff --git a/test/union_test.go b/test/union_test.go index 2211959..d6ac394 100644 --- a/test/union_test.go +++ b/test/union_test.go @@ -31,9 +31,9 @@ var unionTests []testData = []testData{ { selector: `$[?(@.key<3),?(@.key>6)]`, data: `[{"key": 1}, {"key": 8}, {"key": 3}, {"key": 10}, {"key": 7}, {"key": 2}, {"key": 6}, {"key": 4}]`, - expected: nil, + expected: []interface{}{}, consensus: consensusNone, - expectedError: "invalid expression. unexpected token '3' at index 0", + expectedError: "", }, { selector: `$['key','another']`, From a6028f0c5f0fb668c854085b1ab606392a5a7d57 Mon Sep 17 00:00:00 2001 From: Scott McGowan Date: Wed, 26 Jan 2022 20:02:52 +0000 Subject: [PATCH 6/6] chore: update standard script engine readme --- script/standard/README.md | 37 ++++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/script/standard/README.md b/script/standard/README.md index 6066be5..049f86a 100644 --- a/script/standard/README.md +++ b/script/standard/README.md @@ -4,23 +4,26 @@ The standard script engine is a basic implementation of the script.Engine interf ## Supported Operations -|operator|name|supported types| -|-|-|-| -|`\|\|`|logical OR|boolean| -|`&&`|logical AND|boolean| -|`==`|equals|number\|string| -|`!=`|not equals|number\|string| -|`<=`|less than or equal to|number| -|`>=`|greater than or equal to|number| -|`<`|less than|number| -|`>`|greater than|number| -|`=~`|regex|string| -|`+`|plus/addition|number| -|`-`|minus/subtraction|number| -|`**`|power|number| -|`*`|multiplication|number| -|`/`|division|number| -|`%`|modulus|integer| +|operator|name|supported types|description| +|-|-|-|-| +|`\|\|`|logical OR|boolean|return true if left-side OR right-side are true| +|`&&`|logical AND|boolean|return true if left-side AND right-side are true| +|`!`|not|boolean|return true if right-side is false. The is no left-side argument| +|`==`|equals|number\|string|return true if left-side and right-side arguments are equal| +|`!=`|not equals|number\|string|return true if left-side and right-side arguments are not equal| +|`<=`|less than or equal to|number|return true if left-side number is less than or equal to the right-side number| +|`>=`|greater than or equal to|number|return true if left-side number is greater than or equal to the right-side number| +|`<`|less than|number|return true if left-side number is less than the right-side number| +|`>`|greater than|number|return true if left-side number is greater than the right-side number| +|`=~`|regex|string|perform a regex match on the left-side value using the right-side pattern| +|`+`|plus/addition|number|return the left-side number added to the right-side number| +|`-`|minus/subtraction|number|return the left-side number minus the right-side number| +|`**`|power|number|return the left-side number increased to the power of the right-side number| +|`*`|multiplication|number|return the left-side number multiplied by the right-side number| +|`/`|division|number|return the left-side number divided by the right-side number| +|`%`|modulus|integer|return the remainder of the left-side number divided by the right-side number| + +All operators have a left-side and right-side argument, expect the not `!` operator which only as a right-side argument. The arguments can be strings, numbers, boolean values, arrays, objects, a special parameter, or other expressions, for example `true && true || false` includes the logical AND operator with left-side `true` and right-side a logical OR operator with left-side `true` and right-side `false`. ### Regex