Skip to content

Commit e8452fc

Browse files
Merge pull request #9 from danielgtaylor/map-where
feat: where for maps; various small fixes
2 parents 194704c + 2fb6df7 commit e8452fc

File tree

7 files changed

+100
-22
lines changed

7 files changed

+100
-22
lines changed

README.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ Indexes are zero-based. Slice indexes are optional and are _inclusive_. `foo[1:2
187187

188188
#### Array/slice filtering
189189

190-
A `where` clause can be used to filter the items in an array. The left side of the clause is the array to be filtered, while the right side is an expression to run on each item of the array. If the right side expression evaluates to true then the item is added to the result. For example:
190+
A `where` clause can be used to filter the items in an array. The left side of the clause is the array to be filtered, while the right side is an expression to run on each item of the array. If the right side expression evaluates to true then the item is added to the result slice. For example:
191191

192192
```
193193
// Get a list of items where the item.id is bigger than 3
@@ -221,6 +221,33 @@ not (items where id > 3)
221221
- `in` (has key), e.g. `"key" in foo`
222222
- `contains` e.g. `foo contains "key"`
223223

224+
#### Map wildcard filtering
225+
226+
A `where` clause can be used as a wildcard key to filter values for all keys in a map. The left side of the clause is the map to be filtered, while the right side is an expression to run on each value of the map. If the right side expression evaluates to true then the value is added to the result slice. For example, given:
227+
228+
```json
229+
{
230+
"operations": {
231+
"id1": { "method": "GET", "path": "/op1" },
232+
"id2": { "method": "PUT", "path": "/op2" },
233+
"id3": { "method": "DELETE", "path": "/op3" }
234+
}
235+
}
236+
```
237+
238+
You can run:
239+
240+
```
241+
// Get all operations where the HTTP method is GET
242+
operations where method == "GET"
243+
```
244+
245+
And the result would be a slice of matched values:
246+
247+
```json
248+
[{ "method": "GET", "path": "/op1" }]
249+
```
250+
224251
## Performance
225252

226253
Performance compares favorably to [antonmedv/expr](https://github.com/antonmedv/expr) for both `Eval(...)` and cached program performance, which is expected given the more limited feature set. The `slow` benchmarks include lexing/parsing/interpreting while the `cached` ones are just the interpreting step. The `complex` example expression used is non-trivial: `foo.bar / (1 * 1024 * 1024) >= 1.0 and "v" in baz and baz.length > 3 and arr[2:].length == 1`.

expr.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ package mexpr
44
// Parse an expression and return the abstract syntax tree. If `types` is
55
// passed, it should be a set of representative example values for the input
66
// which will be used to type check the expression against.
7-
func Parse(expression string, types map[string]interface{}, options ...InterpreterOption) (*Node, Error) {
7+
func Parse(expression string, types any, options ...InterpreterOption) (*Node, Error) {
88
l := NewLexer(expression)
99
p := NewParser(l)
1010
ast, err := p.Parse()
@@ -13,21 +13,21 @@ func Parse(expression string, types map[string]interface{}, options ...Interpret
1313
}
1414
if types != nil {
1515
if err := TypeCheck(ast, types, options...); err != nil {
16-
return nil, err
16+
return ast, err
1717
}
1818
}
1919
return ast, nil
2020
}
2121

2222
// TypeCheck will take a parsed AST and type check against the given input
2323
// structure with representative example values.
24-
func TypeCheck(ast *Node, types map[string]interface{}, options ...InterpreterOption) Error {
24+
func TypeCheck(ast *Node, types any, options ...InterpreterOption) Error {
2525
i := NewTypeChecker(ast, options...)
2626
return i.Run(types)
2727
}
2828

2929
// Run executes an AST with the given input and returns the output.
30-
func Run(ast *Node, input map[string]interface{}, options ...InterpreterOption) (interface{}, Error) {
30+
func Run(ast *Node, input any, options ...InterpreterOption) (any, Error) {
3131
i := NewInterpreter(ast, options...)
3232
return i.Run(input)
3333
}
@@ -36,7 +36,7 @@ func Run(ast *Node, input map[string]interface{}, options ...InterpreterOption)
3636
// expression with the given input. If you plan to execute the expression
3737
// multiple times consider caching the output of `Parse(...)` instead for a
3838
// big speed improvement.
39-
func Eval(expression string, input map[string]interface{}, options ...InterpreterOption) (interface{}, Error) {
39+
func Eval(expression string, input any, options ...InterpreterOption) (any, Error) {
4040
// No need to type check because we are about to run with the input.
4141
ast, err := Parse(expression, nil)
4242
if err != nil {

go.mod

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ module github.com/danielgtaylor/mexpr
22

33
go 1.18
44

5-
require github.com/stretchr/testify v1.7.0
5+
require (
6+
github.com/stretchr/testify v1.7.0
7+
golang.org/x/exp v0.0.0-20230108222341-4b8118a2686a
8+
)
69

710
require (
811
github.com/davecgh/go-spew v1.1.0 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
55
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
66
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
77
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
8+
golang.org/x/exp v0.0.0-20230108222341-4b8118a2686a h1:tlXy25amD5A7gOfbXdqCGN5k8ESEed/Ee1E5RcrYnqU=
9+
golang.org/x/exp v0.0.0-20230108222341-4b8118a2686a/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
810
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
911
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
1012
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=

interpreter.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package mexpr
33
import (
44
"math"
55
"strings"
6+
7+
"golang.org/x/exp/maps"
68
)
79

810
// InterpreterOption passes configuration settings when creating a new
@@ -434,6 +436,16 @@ func (i *interpreter) run(ast *Node, value any) (any, Error) {
434436
if resultLeft == nil {
435437
return nil, nil
436438
}
439+
if m, ok := resultLeft.(map[string]any); ok {
440+
resultLeft = maps.Values(m)
441+
}
442+
if m, ok := resultLeft.(map[any]any); ok {
443+
values := make([]any, 0, len(m))
444+
for _, v := range m {
445+
values = append(values, v)
446+
}
447+
resultLeft = values
448+
}
437449
for _, item := range resultLeft.([]any) {
438450
// In an unquoted string scenario it makes no sense for the first/only
439451
// token after a `where` clause to be treated as a string. Instead we

interpreter_test.go

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,13 @@ import (
1010

1111
func TestInterpreter(t *testing.T) {
1212
type test struct {
13-
expr string
14-
input string
15-
skipTC bool
16-
opts []InterpreterOption
17-
err string
18-
output interface{}
13+
expr string
14+
input string
15+
inputParsed any
16+
skipTC bool
17+
opts []InterpreterOption
18+
err string
19+
output interface{}
1920
}
2021
cases := []test{
2122
// Add/sub
@@ -71,7 +72,7 @@ func TestInterpreter(t *testing.T) {
7172
{expr: `foo[-1]`, input: `{"foo": "hello"}`, output: "o"},
7273
{expr: `foo[0:-3]`, input: `{"foo": "hello"}`, output: "hel"},
7374
// Unquoted strings
74-
{expr: `"foo" == foo`, output: false},
75+
{expr: `"foo" == foo`, skipTC: true, output: false},
7576
{expr: `"foo" == foo`, opts: []InterpreterOption{UnquotedStrings}, output: true},
7677
{expr: `"foo" == bar`, opts: []InterpreterOption{UnquotedStrings}, output: false},
7778
{expr: `foo == foo`, opts: []InterpreterOption{UnquotedStrings}, output: true},
@@ -142,6 +143,9 @@ func TestInterpreter(t *testing.T) {
142143
{expr: `(items where id > 3).length == 2`, input: `{"items": [{"id": 1}, {"id": 3}, {"id": 5}, {"id": 7}]}`, output: true},
143144
{expr: `not (items where id > 3)`, input: `{"items": [{"id": 1}, {"id": 3}, {"id": 5}, {"id": 7}]}`, output: false},
144145
{expr: `items where id > 3`, input: `{}`, skipTC: true, output: nil},
146+
{expr: `foo where method == "GET"`, input: `{"foo": {"op1": {"method": "GET", "path": "/op1"}, "op2": {"method": "PUT", "path": "/op2"}, "op3": {"method": "DELETE", "path": "/op3"}}}`, output: []any{map[string]any{"method": "GET", "path": "/op1"}}},
147+
{expr: `foo where method == "GET"`, inputParsed: map[any]any{"foo": map[any]any{"op1": map[any]any{"method": "GET", "path": "/op1"}, "op2": map[any]any{"method": "PUT", "path": "/op2"}, "op3": map[any]any{"method": "DELETE", "path": "/op3"}}}, output: []any{map[any]any{"method": "GET", "path": "/op1"}}},
148+
{expr: `items where id > 3`, input: `{"items": []}`, err: "where clause requires a non-empty array or object"},
145149
// Order of operations
146150
{expr: "1 + 2 + 3", output: 6.0},
147151
{expr: "1 + 2 * 3", output: 7.0},
@@ -152,7 +156,7 @@ func TestInterpreter(t *testing.T) {
152156
{expr: "6 -", err: "incomplete expression"},
153157
{expr: `foo.bar + "baz"`, input: `{"foo": 1}`, err: "no property bar"},
154158
{expr: `foo + 1`, input: `{"foo": [1, 2]}`, err: "cannot operate on incompatible types"},
155-
{expr: `foo > 1`, input: `{"foo": []}`, err: "cannot compare array with number"},
159+
{expr: `foo > 1`, input: `{"foo": []}`, err: "cannot compare array[<nil>] with number"},
156160
{expr: `foo[1-]`, input: `{"foo": "hello"}`, err: "unexpected right-bracket"},
157161
{expr: `not (1- <= 5)`, err: "missing right operand"},
158162
{expr: `(1 >=)`, err: "unexpected right-paren"},
@@ -175,8 +179,10 @@ func TestInterpreter(t *testing.T) {
175179

176180
for _, tc := range cases {
177181
t.Run(tc.expr, func(t *testing.T) {
178-
var input map[string]interface{}
179-
if tc.input != "" {
182+
var input any
183+
if tc.inputParsed != nil {
184+
input = tc.inputParsed
185+
} else if tc.input != "" {
180186
if err := json.Unmarshal([]byte(tc.input), &input); err != nil {
181187
t.Fatal(err)
182188
}
@@ -188,6 +194,10 @@ func TestInterpreter(t *testing.T) {
188194
}
189195
ast, err := Parse(tc.expr, types, tc.opts...)
190196

197+
if ast != nil {
198+
t.Log("graph G {\n" + ast.Dot("") + "\n}")
199+
}
200+
191201
if tc.err != "" {
192202
if err != nil {
193203
if strings.Contains(err.Error(), tc.err) {
@@ -200,7 +210,7 @@ func TestInterpreter(t *testing.T) {
200210
t.Fatal(err.Pretty(tc.expr))
201211
}
202212
}
203-
t.Log("graph G {\n" + ast.Dot("") + "\n}")
213+
204214
result, err := Run(ast, input, tc.opts...)
205215
if tc.err != "" {
206216
if err == nil {
@@ -229,7 +239,7 @@ func FuzzMexpr(f *testing.F) {
229239
"f": 1.0,
230240
"s": "Hello",
231241
"a": []any{false, 1, "a"},
232-
"o": map[string]any{
242+
"o": map[any]any{
233243
"prop": 123,
234244
},
235245
})

typecheck.go

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
package mexpr
22

33
import (
4+
"fmt"
5+
"sort"
46
"strings"
7+
8+
"golang.org/x/exp/maps"
59
)
610

711
type valueType string
@@ -22,6 +26,12 @@ type schema struct {
2226
}
2327

2428
func (s *schema) String() string {
29+
if s.isArray() {
30+
return fmt.Sprintf("%s[%s]", s.typeName, s.items)
31+
}
32+
if s.isObject() {
33+
return fmt.Sprintf("%s{%v}", s.typeName, maps.Keys(s.properties))
34+
}
2535
return string(s.typeName)
2636
}
2737

@@ -37,6 +47,10 @@ func (s *schema) isArray() bool {
3747
return s != nil && s.typeName == typeArray
3848
}
3949

50+
func (s *schema) isObject() bool {
51+
return s != nil && s.typeName == typeObject
52+
}
53+
4054
var (
4155
schemaBool = newSchema(typeBool)
4256
schemaNumber = newSchema(typeNumber)
@@ -270,8 +284,18 @@ func (i *typeChecker) run(ast *Node, value any) (*schema, Error) {
270284
if err != nil {
271285
return nil, err
272286
}
273-
if !leftType.isArray() {
274-
return nil, NewError(ast.Offset, ast.Length, "where clause requires an array, but found %s", leftType)
287+
if leftType.isObject() {
288+
keys := maps.Keys(leftType.properties)
289+
sort.Strings(keys)
290+
if len(keys) > 0 {
291+
// Pick the first prop as the representative item type.
292+
prop := leftType.properties[keys[0]]
293+
leftType = newSchema(typeArray)
294+
leftType.items = prop
295+
}
296+
}
297+
if !leftType.isArray() || leftType.items == nil {
298+
return nil, NewError(ast.Offset, ast.Length, "where clause requires a non-empty array or object, but found %s", leftType)
275299
}
276300
// In an unquoted string scenario it makes no sense for the first/only
277301
// token after a `where` clause to be treated as a string. Instead we
@@ -289,5 +313,5 @@ func (i *typeChecker) run(ast *Node, value any) (*schema, Error) {
289313
}
290314
return schemaBool, nil
291315
}
292-
return nil, NewError(ast.Offset, ast.Length, "unexpected node")
316+
return nil, NewError(ast.Offset, ast.Length, "unexpected node %v", ast)
293317
}

0 commit comments

Comments
 (0)