From 14d466ee1c87e148e2c2ccf16d61a1cf64fc21cd Mon Sep 17 00:00:00 2001 From: sawy3r Date: Fri, 18 Sep 2026 05:30:25 +1000 Subject: [PATCH] feat: allow collection builtins to iterate maps all, any, none, one, count, sum, filter, map, find, findLast and reduce previously accepted only arrays, and min, max, mean and median rejected map arguments too. That made groupBy() a dead end: it returns a map, and nothing downstream could consume it, so a pipeline like events | groupBy(.Author) | filter(len(#) > 1) could not be written. These builtins now also accept a map with string keys (including a map declared with a loose key type that holds string keys at evaluation time, such as the ones groupBy produces), and iterate its entries in ascending key order, comparing keys as strings byte by byte. Inside a predicate, # holds the entry's value and #index holds its key; #index was previously only available in map and reduce, and now becomes available in all, any, none, one, count, sum, find and findLast too. For arrays #index keeps holding the element's position, so the two collection kinds stay symmetric. filter applied to a map returns a map holding the entries whose predicate holds, and map returns a map with the same keys and transformed values, so both compose and their results keep behaving like a map downstream (indexing, len, keys, sum, the in operator). The other builtins return the same shape they return for arrays: all, any, none and one report on the values, count and sum work as they do for arrays, find and findLast return the first and last matching value in key order, and reduce folds the values in key order. An empty or nil map behaves the way an empty array does. min, max, mean and median aggregate a map's values and mix freely with array and scalar arguments. findIndex, findLastIndex, groupBy and sortBy keep rejecting maps, since positions and reordering don't translate to map entries. Maps with non-string keys keep being rejected, at compile time when the environment types make that visible, otherwise at runtime. This works whether or not the expression is compiled against a typed environment; with one, the type checker accepts the new forms and carries sensible types through them (the key type for #index, the value type for #, and a map for filter/map results). Array behavior is unchanged. Adds test/issues/575/issue_test.go covering the new behavior end to end, plus a handful of cases folded into the existing builtin and checker test tables, and documents map support in the affected builtins. Closes #575. Co-Authored-By: Claude Fable 5.1 --- builtin/builtin_test.go | 16 ++ builtin/lib.go | 45 +++++ builtin/validation.go | 9 +- checker/checker.go | 88 ++++++--- checker/checker_test.go | 16 +- compiler/compiler.go | 8 +- compiler/compiler_test.go | 25 +-- docs/language-definition.md | 24 ++- test/issues/575/issue_test.go | 310 ++++++++++++++++++++++++++++++ testdata/generated.txt | 351 ---------------------------------- vm/opcodes.go | 3 + vm/program.go | 9 + vm/utils.go | 27 +++ vm/vm.go | 63 +++++- 14 files changed, 592 insertions(+), 402 deletions(-) create mode 100644 test/issues/575/issue_test.go diff --git a/builtin/builtin_test.go b/builtin/builtin_test.go index 0d0dec357..f967c4769 100644 --- a/builtin/builtin_test.go +++ b/builtin/builtin_test.go @@ -34,6 +34,7 @@ func TestBuiltin(t *testing.T) { "NestedAnyArrays": []any{[]any{1, 2}, []any{3, 4}}, "MixedNestedArray": []any{1, []int{2, 3}, []float64{4.0, 5.0}}, "NestedInt32Array": []any{[]int32{1, 2}, []int32{3, 4}}, + "MapOfInt": map[string]int{"a": 1, "b": 2, "c": 3}, } var tests = []struct { @@ -197,6 +198,21 @@ func TestBuiltin(t *testing.T) { {`flatten([["a", "b"], [1, 2, [3, [[[["c", "d"], "e"]]], 4]]])`, []any{"a", "b", 1, 2, 3, "c", "d", "e", 4}}, {`uniq([1, 15, "a", 2, 3, 5, 2, "a", 2, "b"])`, []any{1, 15, "a", 2, 3, 5, "b"}}, {`uniq([[1, 2], "a", 2, 3, [1, 2], [1, 3]])`, []any{[]any{1, 2}, "a", 2, 3, []any{1, 3}}}, + {`filter(MapOfInt, # > 1)`, map[string]any{"b": 2, "c": 3}}, + {`map(MapOfInt, # * 10)`, map[string]any{"a": 10, "b": 20, "c": 30}}, + {`all(MapOfInt, # > 0)`, true}, + {`any(MapOfInt, # > 2)`, true}, + {`none(MapOfInt, # > 3)`, true}, + {`one(MapOfInt, # == 2)`, true}, + {`count(MapOfInt, # > 1)`, 2}, + {`sum(MapOfInt)`, 6}, + {`find(MapOfInt, # > 1)`, 2}, + {`findLast(MapOfInt, # > 1)`, 3}, + {`reduce(MapOfInt, #acc + #, 0)`, 6}, + {`min(MapOfInt)`, 1}, + {`max(MapOfInt)`, 3}, + {`mean(MapOfInt)`, 2.0}, + {`median(MapOfInt)`, 2.0}, } for _, test := range tests { diff --git a/builtin/lib.go b/builtin/lib.go index 61748da08..0ebf63fdf 100644 --- a/builtin/lib.go +++ b/builtin/lib.go @@ -233,12 +233,47 @@ func String(arg any) any { return fmt.Sprintf("%v", arg) } +// aggregateMapValues unwraps a map argument of an aggregation builtin into a +// slice of its values. It reports whether the argument was a map. Only maps +// with string keys can be aggregated. +func aggregateMapValues(arg any) (any, bool, error) { + if m, ok := arg.(map[string]any); ok { + values := make([]any, 0, len(m)) + for _, v := range m { + values = append(values, v) + } + return values, true, nil + } + rv := reflect.ValueOf(arg) + if rv.Kind() != reflect.Map { + return nil, false, nil + } + values := make([]any, 0, rv.Len()) + iter := rv.MapRange() + for iter.Next() { + k := iter.Key() + if k.Kind() == reflect.Interface { + k = k.Elem() + } + if k.Kind() != reflect.String { + return nil, true, fmt.Errorf("cannot aggregate over map with non-string keys (%T)", arg) + } + values = append(values, iter.Value().Interface()) + } + return values, true, nil +} + func minMax(name string, fn func(any, any) bool, depth int, args ...any) (any, error) { if depth > MaxDepth { return nil, ErrorMaxDepth } var val any for _, arg := range args { + if values, ok, err := aggregateMapValues(arg); err != nil { + return nil, err + } else if ok { + arg = values + } // Fast paths for common typed slices - avoid reflection and allocations switch arr := arg.(type) { case []int: @@ -353,6 +388,11 @@ func mean(depth int, args ...any) (int, float64, error) { var count int for _, arg := range args { + if values, ok, err := aggregateMapValues(arg); err != nil { + return 0, 0, err + } else if ok { + arg = values + } // Fast paths for common typed slices - avoid reflection and allocations switch arr := arg.(type) { case []int: @@ -449,6 +489,11 @@ func median(depth int, args ...any) ([]float64, error) { var values []float64 for _, arg := range args { + if mapValues, ok, err := aggregateMapValues(arg); err != nil { + return nil, err + } else if ok { + arg = mapValues + } // Fast paths for common typed slices - avoid reflection and allocations switch arr := arg.(type) { case []int: diff --git a/builtin/validation.go b/builtin/validation.go index 057f247e9..af77f7510 100644 --- a/builtin/validation.go +++ b/builtin/validation.go @@ -13,9 +13,16 @@ func validateAggregateFunc(name string, args []reflect.Type) (reflect.Type, erro return anyType, fmt.Errorf("not enough arguments to call %s", name) default: for _, arg := range args { - switch kind(deref.Type(arg)) { + t := deref.Type(arg) + switch kind(t) { case reflect.Interface, reflect.Array, reflect.Slice: return anyType, nil + case reflect.Map: + keyKind := t.Key().Kind() + if keyKind != reflect.String && !(keyKind == reflect.Interface && t.Key().NumMethod() == 0) { + return anyType, fmt.Errorf("invalid argument for %s (type %s)", name, arg) + } + return anyType, nil case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Float32, reflect.Float64: default: return anyType, fmt.Errorf("invalid argument for %s (type %s)", name, arg) diff --git a/checker/checker.go b/checker/checker.go index 63425af1f..0fb70354d 100644 --- a/checker/checker.go +++ b/checker/checker.go @@ -701,16 +701,44 @@ func (v *Checker) callNode(node *ast.CallNode) Nature { return v.error(node, "%s is not callable", nt.String()) } +// isIterable reports whether a collection nature can be iterated by the +// predicate builtins: an array, or a map with string keys. +func (v *Checker) isIterable(collection Nature) bool { + if collection.IsArray() { + return true + } + if collection.IsMap() { + key := collection.Key(&v.config.NtCache) + if key.Kind == reflect.String { + return true + } + return key.Kind == reflect.Interface && key.Type.NumMethod() == 0 + } + return false +} + +// indexNature returns the nature of the #index pointer for a collection: +// the key nature for maps, int for arrays, unknown for unknown collections. +func (v *Checker) indexNature(collection Nature) Nature { + if collection.IsUnknown(&v.config.NtCache) { + return Nature{} + } + if collection.IsMap() { + return collection.Key(&v.config.NtCache) + } + return v.config.NtCache.FromType(intType) +} + func (v *Checker) builtinNode(node *ast.BuiltinNode) Nature { switch node.Name { case "all", "none", "any", "one": collection := v.visit(node.Arguments[0]) collection = collection.Deref(&v.config.NtCache) - if !collection.IsArray() && !collection.IsUnknown(&v.config.NtCache) { - return v.error(node.Arguments[0], "builtin %v takes only array (got %v)", node.Name, collection.String()) + if !v.isIterable(collection) && !collection.IsUnknown(&v.config.NtCache) { + return v.error(node.Arguments[0], "builtin %v takes only array or map with string keys (got %v)", node.Name, collection.String()) } - v.begin(collection) + v.begin(collection, varScope{"index", v.indexNature(collection)}) predicate := v.visit(node.Arguments[1]) v.end() @@ -729,11 +757,11 @@ func (v *Checker) builtinNode(node *ast.BuiltinNode) Nature { case "filter": collection := v.visit(node.Arguments[0]) collection = collection.Deref(&v.config.NtCache) - if !collection.IsArray() && !collection.IsUnknown(&v.config.NtCache) { - return v.error(node.Arguments[0], "builtin %v takes only array (got %v)", node.Name, collection.String()) + if !v.isIterable(collection) && !collection.IsUnknown(&v.config.NtCache) { + return v.error(node.Arguments[0], "builtin %v takes only array or map with string keys (got %v)", node.Name, collection.String()) } - v.begin(collection) + v.begin(collection, varScope{"index", v.indexNature(collection)}) predicate := v.visit(node.Arguments[1]) v.end() @@ -748,6 +776,9 @@ func (v *Checker) builtinNode(node *ast.BuiltinNode) Nature { if collection.IsUnknown(&v.config.NtCache) { return v.config.NtCache.FromType(arrayType) } + if collection.IsMap() { + return collection + } collection = collection.Elem(&v.config.NtCache) return collection.MakeArrayOf(&v.config.NtCache) } @@ -756,11 +787,11 @@ func (v *Checker) builtinNode(node *ast.BuiltinNode) Nature { case "map": collection := v.visit(node.Arguments[0]) collection = collection.Deref(&v.config.NtCache) - if !collection.IsArray() && !collection.IsUnknown(&v.config.NtCache) { - return v.error(node.Arguments[0], "builtin %v takes only array (got %v)", node.Name, collection.String()) + if !v.isIterable(collection) && !collection.IsUnknown(&v.config.NtCache) { + return v.error(node.Arguments[0], "builtin %v takes only array or map with string keys (got %v)", node.Name, collection.String()) } - v.begin(collection, varScope{"index", v.config.NtCache.FromType(intType)}) + v.begin(collection, varScope{"index", v.indexNature(collection)}) predicate := v.visit(node.Arguments[1]) v.end() @@ -768,6 +799,13 @@ func (v *Checker) builtinNode(node *ast.BuiltinNode) Nature { predicate.NumOut() == 1 && predicate.NumIn() == 1 && predicate.IsFirstArgUnknown(&v.config.NtCache) { + if collection.IsMap() { + elemType := anyType + if predicate.Ref != nil && predicate.Ref.Type != nil { + elemType = predicate.Ref.Type + } + return v.config.NtCache.FromType(reflect.MapOf(collection.Type.Key(), elemType)) + } return predicate.Ref.MakeArrayOf(&v.config.NtCache) } return v.error(node.Arguments[1], "predicate should has one input and one output param") @@ -775,15 +813,15 @@ func (v *Checker) builtinNode(node *ast.BuiltinNode) Nature { case "count": collection := v.visit(node.Arguments[0]) collection = collection.Deref(&v.config.NtCache) - if !collection.IsArray() && !collection.IsUnknown(&v.config.NtCache) { - return v.error(node.Arguments[0], "builtin %v takes only array (got %v)", node.Name, collection.String()) + if !v.isIterable(collection) && !collection.IsUnknown(&v.config.NtCache) { + return v.error(node.Arguments[0], "builtin %v takes only array or map with string keys (got %v)", node.Name, collection.String()) } if len(node.Arguments) == 1 { return v.config.NtCache.FromType(intType) } - v.begin(collection) + v.begin(collection, varScope{"index", v.indexNature(collection)}) predicate := v.visit(node.Arguments[1]) v.end() @@ -802,12 +840,12 @@ func (v *Checker) builtinNode(node *ast.BuiltinNode) Nature { case "sum": collection := v.visit(node.Arguments[0]) collection = collection.Deref(&v.config.NtCache) - if !collection.IsArray() && !collection.IsUnknown(&v.config.NtCache) { - return v.error(node.Arguments[0], "builtin %v takes only array (got %v)", node.Name, collection.String()) + if !v.isIterable(collection) && !collection.IsUnknown(&v.config.NtCache) { + return v.error(node.Arguments[0], "builtin %v takes only array or map with string keys (got %v)", node.Name, collection.String()) } if len(node.Arguments) == 2 { - v.begin(collection) + v.begin(collection, varScope{"index", v.indexNature(collection)}) predicate := v.visit(node.Arguments[1]) v.end() @@ -826,11 +864,11 @@ func (v *Checker) builtinNode(node *ast.BuiltinNode) Nature { case "find", "findLast": collection := v.visit(node.Arguments[0]) collection = collection.Deref(&v.config.NtCache) - if !collection.IsArray() && !collection.IsUnknown(&v.config.NtCache) { - return v.error(node.Arguments[0], "builtin %v takes only array (got %v)", node.Name, collection.String()) + if !v.isIterable(collection) && !collection.IsUnknown(&v.config.NtCache) { + return v.error(node.Arguments[0], "builtin %v takes only array or map with string keys (got %v)", node.Name, collection.String()) } - v.begin(collection) + v.begin(collection, varScope{"index", v.indexNature(collection)}) predicate := v.visit(node.Arguments[1]) v.end() @@ -856,7 +894,7 @@ func (v *Checker) builtinNode(node *ast.BuiltinNode) Nature { return v.error(node.Arguments[0], "builtin %v takes only array (got %v)", node.Name, collection.String()) } - v.begin(collection) + v.begin(collection, varScope{"index", v.indexNature(collection)}) predicate := v.visit(node.Arguments[1]) v.end() @@ -879,7 +917,7 @@ func (v *Checker) builtinNode(node *ast.BuiltinNode) Nature { return v.error(node.Arguments[0], "builtin %v takes only array (got %v)", node.Name, collection.String()) } - v.begin(collection) + v.begin(collection, varScope{"index", v.indexNature(collection)}) predicate := v.visit(node.Arguments[1]) v.end() @@ -902,7 +940,7 @@ func (v *Checker) builtinNode(node *ast.BuiltinNode) Nature { return v.error(node.Arguments[0], "builtin %v takes only array (got %v)", node.Name, collection.String()) } - v.begin(collection) + v.begin(collection, varScope{"index", v.indexNature(collection)}) predicate := v.visit(node.Arguments[1]) v.end() @@ -924,11 +962,11 @@ func (v *Checker) builtinNode(node *ast.BuiltinNode) Nature { case "reduce": collection := v.visit(node.Arguments[0]) collection = collection.Deref(&v.config.NtCache) - if !collection.IsArray() && !collection.IsUnknown(&v.config.NtCache) { - return v.error(node.Arguments[0], "builtin %v takes only array (got %v)", node.Name, collection.String()) + if !v.isIterable(collection) && !collection.IsUnknown(&v.config.NtCache) { + return v.error(node.Arguments[0], "builtin %v takes only array or map with string keys (got %v)", node.Name, collection.String()) } - v.begin(collection, varScope{"index", v.config.NtCache.FromType(intType)}, varScope{"acc", Nature{}}) + v.begin(collection, varScope{"index", v.indexNature(collection)}, varScope{"acc", Nature{}}) predicate := v.visit(node.Arguments[1]) v.end() @@ -1241,7 +1279,7 @@ func (v *Checker) pointerNode(node *ast.PointerNode) Nature { return Nature{} } switch scope.collection.Kind { - case reflect.Array, reflect.Slice: + case reflect.Array, reflect.Slice, reflect.Map: return scope.collection.Elem(&v.config.NtCache) } return v.error(node, "cannot use %v as array", scope) diff --git a/checker/checker_test.go b/checker/checker_test.go index 7a581612a..43ea08b63 100644 --- a/checker/checker_test.go +++ b/checker/checker_test.go @@ -477,7 +477,7 @@ cannot use string as argument (type int) to call Variadic (1:13) `, }, {`count(1, {#})`, ` -builtin count takes only array (got int) (1:7) +builtin count takes only array or map with string keys (got int) (1:7) | count(1, {#}) | ......^ `, @@ -507,7 +507,7 @@ predicate should return boolean (got string) (1:18) `, }, {`map(1, {2})`, ` -builtin map takes only array (got int) (1:5) +builtin map takes only array or map with string keys (got int) (1:5) | map(1, {2}) | ....^ `, @@ -549,13 +549,13 @@ invalid argument for len (type int) (1:1) `, }, {`any(42, {#})`, ` -builtin any takes only array (got int) (1:5) +builtin any takes only array or map with string keys (got int) (1:5) | any(42, {#}) | ....^ `, }, {`filter(42, {#})`, ` -builtin filter takes only array (got int) (1:8) +builtin filter takes only array or map with string keys (got int) (1:8) | filter(42, {#}) | .......^ `, @@ -1146,6 +1146,9 @@ func TestCheck_types(t *testing.T) { "arr": types.Array(types.Map{ "value": types.String, }), + "prices": types.Map{ + types.Extra: types.Int, + }, types.Extra: types.Any, } @@ -1164,6 +1167,11 @@ func TestCheck_types(t *testing.T) { {`[foo] | map(.bar) | filter(.baz)`, `predicate should return boolean (got string)`}, {`arr | filter(.value > 0)`, `invalid operation: > (mismatched types string and int)`}, {`arr | filter(.value contains "a") | filter(.value == 0)`, `invalid operation: == (mismatched types string and int)`}, + {`prices | filter(# > 0)`, noerr}, + {`prices | filter(# > 0) | sum()`, noerr}, + {`prices | map(# * 2)`, noerr}, + {`prices | filter(#index startsWith "a")`, noerr}, + {`prices | filter(# + "x" > 0)`, `invalid operation: + (mismatched types int and string)`}, } c := new(checker.Checker) diff --git a/compiler/compiler.go b/compiler/compiler.go index 685175350..b748c4e75 100644 --- a/compiler/compiler.go +++ b/compiler/compiler.go @@ -909,6 +909,7 @@ func (c *compiler) BuiltinNode(node *ast.BuiltinNode) { c.compile(node.Arguments[1]) c.emitCond(func() { c.emit(OpIncrementCount) + c.emit(OpMarkKey) if node.Map != nil { c.compile(node.Map) } else { @@ -917,8 +918,8 @@ func (c *compiler) BuiltinNode(node *ast.BuiltinNode) { }) }) c.emit(OpGetCount) + c.emit(OpCollect) c.emit(OpEnd) - c.emit(OpArray) return case "map": @@ -926,11 +927,12 @@ func (c *compiler) BuiltinNode(node *ast.BuiltinNode) { c.derefInNeeded(node.Arguments[0]) c.emit(OpBegin) c.emitLoop(func() { + c.emit(OpMarkKey) c.compile(node.Arguments[1]) }) c.emit(OpGetLen) + c.emit(OpCollect) c.emit(OpEnd) - c.emit(OpArray) return case "count": @@ -1230,7 +1232,7 @@ func (c *compiler) PredicateNode(node *ast.PredicateNode) { func (c *compiler) PointerNode(node *ast.PointerNode) { switch node.Name { case "index": - c.emit(OpGetIndex) + c.emit(OpPointerIndex) case "acc": c.emit(OpGetAcc) case "": diff --git a/compiler/compiler_test.go b/compiler/compiler_test.go index 6efce686f..fa3a667ef 100644 --- a/compiler/compiler_test.go +++ b/compiler/compiler_test.go @@ -517,33 +517,34 @@ func TestCompile_optimizes_jumps(t *testing.T) { `filter([1, 2, 3, 4, 5], # > 3 && # != 4 && # != 5)`, `0 OpPush <0> [1 2 3 4 5] 1 OpBegin -2 OpJumpIfEnd <23> (26) +2 OpJumpIfEnd <24> (27) 3 OpPointer 4 OpPush <1> 3 5 OpMore -6 OpJumpIfFalse <16> (23) +6 OpJumpIfFalse <17> (24) 7 OpPop 8 OpPointer 9 OpPush <2> 4 10 OpEqualInt 11 OpNot -12 OpJumpIfFalse <10> (23) +12 OpJumpIfFalse <11> (24) 13 OpPop 14 OpPointer 15 OpPush <3> 5 16 OpEqualInt 17 OpNot -18 OpJumpIfFalse <4> (23) +18 OpJumpIfFalse <5> (24) 19 OpPop 20 OpIncrementCount -21 OpPointer -22 OpJump <1> (24) -23 OpPop -24 OpIncrementIndex -25 OpJumpBackward <24> (2) -26 OpGetCount -27 OpEnd -28 OpArray +21 OpMarkKey +22 OpPointer +23 OpJump <1> (25) +24 OpPop +25 OpIncrementIndex +26 OpJumpBackward <25> (2) +27 OpGetCount +28 OpCollect +29 OpEnd `, }, { diff --git a/docs/language-definition.md b/docs/language-definition.md index 69efbdfa9..d096feed7 100644 --- a/docs/language-definition.md +++ b/docs/language-definition.md @@ -334,6 +334,19 @@ Braces `{` `}` can be omitted: filter(tweets, len(.Content) > 240) ``` +The collection can also be a map with string keys. Entries are visited in +ascending key order; `#` holds the entry's value and `#index` holds its key +(for arrays, `#index` keeps holding the element's position). + +```expr +events | groupBy(.Author) | filter(len(#) > 1) +``` + +`filter` and `map` applied to a map return a map, keeping the original keys; +the other predicate builtins on this page return the same shape they return +for arrays. `findIndex`, `findLastIndex`, `groupBy` and `sortBy` still only +accept arrays, since positions and reordering don't translate to map entries. + :::tip In nested predicates, to access the outer variable, use [variables](#variables). @@ -763,7 +776,7 @@ Following variables are available in the predicate: - `#` - the current element - `#acc` - the accumulator -- `#index` - the index of the current element +- `#index` - the index of the current element (or the key, when reducing a map) ```expr reduce(1..9, #acc + #) @@ -772,7 +785,8 @@ reduce(1..9, #acc + #, 0) ### sum(array[, predicate]) {#sum} -Returns the sum of all numbers in the array. +Returns the sum of all numbers in the array. `array` may also be a map with +string keys, in which case the values are summed. ```expr sum([1, 2, 3]) == 6 @@ -795,7 +809,8 @@ sum(map(accounts, .Balance)) ### mean(array) {#mean} -Returns the average of all numbers in the array. +Returns the average of all numbers in the array. `array` may also be a map +with string keys, in which case the values are averaged. ```expr mean([1, 2, 3]) == 2.0 @@ -803,7 +818,8 @@ mean([1, 2, 3]) == 2.0 ### median(array) {#median} -Returns the median of all numbers in the array. +Returns the median of all numbers in the array. `array` may also be a map +with string keys, in which case the median is taken over the values. ```expr median([1, 2, 3]) == 2.0 diff --git a/test/issues/575/issue_test.go b/test/issues/575/issue_test.go new file mode 100644 index 000000000..5d7a62457 --- /dev/null +++ b/test/issues/575/issue_test.go @@ -0,0 +1,310 @@ +// Package issue575 tests that the collection builtins (all, any, none, one, +// count, sum, filter, map, find, findLast, reduce, min, max, mean, median) +// work on maps with string keys, not just arrays. +// +// See https://github.com/expr-lang/expr/issues/575. +package issue575 + +import ( + "reflect" + "testing" + + "github.com/expr-lang/expr" + "github.com/expr-lang/expr/internal/testify/assert" + "github.com/expr-lang/expr/internal/testify/require" +) + +// asStringMap normalizes any map result into a map[string]any so assertions +// do not depend on the concrete map type a program returns. +func asStringMap(t *testing.T, out any) map[string]any { + t.Helper() + rv := reflect.ValueOf(out) + require.Equal(t, reflect.Map, rv.Kind(), "expected a map result, got %T", out) + m := make(map[string]any, rv.Len()) + iter := rv.MapRange() + for iter.Next() { + k := iter.Key() + if k.Kind() == reflect.Interface { + k = k.Elem() + } + require.Equal(t, reflect.String, k.Kind(), "expected string map keys, got %v", k.Kind()) + m[k.String()] = iter.Value().Interface() + } + return m +} + +func runTyped(t *testing.T, code string, env any) any { + t.Helper() + program, err := expr.Compile(code, expr.Env(env)) + require.NoError(t, err) + out, err := expr.Run(program, env) + require.NoError(t, err) + return out +} + +func runUntyped(t *testing.T, code string, env any) any { + t.Helper() + program, err := expr.Compile(code) + require.NoError(t, err) + out, err := expr.Run(program, env) + require.NoError(t, err) + return out +} + +func testEnv() map[string]any { + return map[string]any{ + "prices": map[string]int{"banana": 3, "apple": 1, "cherry": 5}, + "ratios": map[string]float64{"x": 1.5, "y": 2.5, "z": 3.5}, + "flags": map[string]bool{"a": true, "b": false, "c": true}, + "tags": map[string]string{"b": "beta", "a": "alpha", "c": "gamma"}, + "nested": map[string]any{"evens": []int{2, 4}, "odds": []int{1, 3, 5}}, + "items": []int{10, 20, 30}, + } +} + +func TestIssue575_filter_and_map_over_maps(t *testing.T) { + env := testEnv() + + out := runTyped(t, `filter(prices, # > 2)`, env) + assert.Equal(t, map[string]any{"banana": 3, "cherry": 5}, asStringMap(t, out)) + + out = runTyped(t, `filter(prices, # > 100)`, env) + assert.Len(t, asStringMap(t, out), 0) + + out = runTyped(t, `map(prices, # * 10)`, env) + assert.Equal(t, map[string]any{"apple": 10, "banana": 30, "cherry": 50}, asStringMap(t, out)) + + out = runTyped(t, `map(tags, upper(#))`, env) + assert.Equal(t, map[string]any{"a": "ALPHA", "b": "BETA", "c": "GAMMA"}, asStringMap(t, out)) + + // map() over a map keeps the original keys. + out = runTyped(t, `map(prices, #index)`, env) + assert.Equal(t, map[string]any{"apple": "apple", "banana": "banana", "cherry": "cherry"}, asStringMap(t, out)) +} + +func TestIssue575_all_any_none_one_over_maps(t *testing.T) { + env := testEnv() + assert.Equal(t, true, runTyped(t, `all(prices, # > 0)`, env)) + assert.Equal(t, false, runTyped(t, `all(prices, # > 1)`, env)) + assert.Equal(t, true, runTyped(t, `any(prices, # > 4)`, env)) + assert.Equal(t, false, runTyped(t, `any(prices, # > 100)`, env)) + assert.Equal(t, true, runTyped(t, `none(prices, # > 100)`, env)) + assert.Equal(t, false, runTyped(t, `none(prices, # > 4)`, env)) + assert.Equal(t, true, runTyped(t, `one(prices, # == 3)`, env)) + assert.Equal(t, false, runTyped(t, `one(prices, # > 1)`, env)) +} + +func TestIssue575_count_and_sum_over_maps(t *testing.T) { + env := testEnv() + assert.Equal(t, 2, runTyped(t, `count(prices, # > 2)`, env)) + assert.Equal(t, 0, runTyped(t, `count(prices, # > 100)`, env)) + // Without a predicate, count() counts truthy values. + assert.Equal(t, 2, runTyped(t, `count(flags)`, env)) + + assert.Equal(t, 9, runTyped(t, `sum(prices)`, env)) + assert.Equal(t, 7.5, runTyped(t, `sum(ratios)`, env)) + assert.Equal(t, 18, runTyped(t, `sum(prices, # * 2)`, env)) +} + +func TestIssue575_find_and_findLast_use_key_order(t *testing.T) { + env := testEnv() + // Ascending key order: apple=1, banana=3, cherry=5. + assert.Equal(t, 1, runTyped(t, `find(prices, # > 0)`, env)) + assert.Equal(t, 3, runTyped(t, `find(prices, # > 1)`, env)) + assert.Equal(t, 5, runTyped(t, `findLast(prices, # > 0)`, env)) + assert.Equal(t, 3, runTyped(t, `findLast(prices, # < 5)`, env)) + assert.Nil(t, runTyped(t, `find(prices, # > 100)`, env)) + assert.Nil(t, runTyped(t, `findLast(prices, # > 100)`, env)) +} + +func TestIssue575_reduce_folds_values_in_key_order(t *testing.T) { + env := testEnv() + assert.Equal(t, 9, runTyped(t, `reduce(prices, #acc + #, 0)`, env)) + assert.Equal(t, "alpha|beta|gamma|", runTyped(t, `reduce(tags, #acc + # + "|", "")`, env)) + // #index is available in reduce too, and holds the key. + assert.Equal(t, "abc", runTyped(t, `reduce(tags, #acc + #index, "")`, env)) +} + +func TestIssue575_index_is_the_map_key(t *testing.T) { + env := testEnv() + out := runTyped(t, `filter(prices, #index startsWith "b")`, env) + assert.Equal(t, map[string]any{"banana": 3}, asStringMap(t, out)) + assert.Equal(t, 1, runTyped(t, `count(prices, #index == "cherry")`, env)) + assert.Equal(t, 5, runTyped(t, `find(prices, #index == "cherry")`, env)) + assert.Equal(t, true, runTyped(t, `any(tags, #index == "a")`, env)) +} + +func TestIssue575_index_still_holds_position_for_arrays(t *testing.T) { + env := testEnv() + // #index becomes available in all these predicates for arrays too, + // but keeps holding the element's position, not a key. + out := runTyped(t, `filter(items, #index > 0)`, env) + assert.Equal(t, []any{20, 30}, out) + assert.Equal(t, 20, runTyped(t, `find(items, #index == 1)`, env)) + assert.Equal(t, 1, runTyped(t, `count(items, #index == 2)`, env)) + assert.Equal(t, true, runTyped(t, `all(items, #index < 3)`, env)) +} + +func TestIssue575_empty_and_nil_maps_behave_like_empty_arrays(t *testing.T) { + var nilMap map[string]int + env := map[string]any{"empty": map[string]int{}, "vacant": nilMap} + for _, name := range []string{"empty", "vacant"} { + assert.Equal(t, true, runTyped(t, `all(`+name+`, # > 0)`, env), name) + assert.Equal(t, false, runTyped(t, `any(`+name+`, # > 0)`, env), name) + assert.Equal(t, true, runTyped(t, `none(`+name+`, # > 0)`, env), name) + assert.Equal(t, false, runTyped(t, `one(`+name+`, # > 0)`, env), name) + assert.Equal(t, 0, runTyped(t, `count(`+name+`, # > 0)`, env), name) + assert.Equal(t, 0, runTyped(t, `sum(`+name+`)`, env), name) + assert.Nil(t, runTyped(t, `find(`+name+`, # > 0)`, env), name) + assert.Len(t, asStringMap(t, runTyped(t, `filter(`+name+`, # > 0)`, env)), 0, name) + assert.Len(t, asStringMap(t, runTyped(t, `map(`+name+`, #)`, env)), 0, name) + } +} + +func TestIssue575_nested_map_and_array_predicates(t *testing.T) { + env := testEnv() + // Outer predicate iterates a map, inner one iterates the value array. + out := runTyped(t, `filter(nested, all(#, # % 2 == 0))`, env) + assert.Equal(t, map[string]any{"evens": []int{2, 4}}, asStringMap(t, out)) +} + +func TestIssue575_filter_and_map_compose(t *testing.T) { + env := testEnv() + assert.Equal(t, 2, runTyped(t, `len(filter(prices, # > 2))`, env)) + + out := runTyped(t, `map(filter(prices, # > 2), # * 100)`, env) + assert.Equal(t, map[string]any{"banana": 300, "cherry": 500}, asStringMap(t, out)) + + assert.Equal(t, 8, runTyped(t, `sum(filter(prices, # > 2))`, env)) + + out = runTyped(t, `filter(map(prices, # * 2), # > 5)`, env) + assert.Equal(t, map[string]any{"banana": 6, "cherry": 10}, asStringMap(t, out)) +} + +func TestIssue575_map_and_filter_results_feed_map_operations(t *testing.T) { + env := testEnv() + assert.Equal(t, 30, runTyped(t, `map(prices, # * 10)["banana"]`, env)) + assert.Equal(t, 5, runTyped(t, `filter(prices, # > 2)["cherry"]`, env)) + assert.Equal(t, true, runTyped(t, `"banana" in filter(prices, # > 2)`, env)) + assert.Equal(t, false, runTyped(t, `"apple" in filter(prices, # > 2)`, env)) + keys := runTyped(t, `keys(filter(prices, # > 2))`, env) + assert.ElementsMatch(t, []any{"banana", "cherry"}, keys) +} + +type issue575Event struct { + Author string + Rating float64 +} + +func TestIssue575_groupBy_pipeline(t *testing.T) { + // This is the motivating example from the issue: groupBy() produces a + // map, and previously nothing downstream could consume it. + env := map[string]any{ + "events": []issue575Event{ + {"ann", 7.0}, + {"bob", 9.1}, + {"bob", 8.5}, + {"cat", 9.9}, + }, + } + out := runTyped(t, `events | groupBy(.Author) | filter(len(#) > 1)`, env) + m := asStringMap(t, out) + require.Len(t, m, 1) + require.Contains(t, m, "bob") + + out = runTyped(t, `events | groupBy(.Author) | map(len(#))`, env) + assert.Equal(t, map[string]any{"ann": 1, "bob": 2, "cat": 1}, asStringMap(t, out)) + + assert.Equal(t, 2, runTyped(t, `events | groupBy(.Author) | count(all(#, .Rating > 8.0))`, env)) +} + +func TestIssue575_min_max_mean_median_aggregate_map_values(t *testing.T) { + env := testEnv() + assert.Equal(t, 1, runTyped(t, `min(prices)`, env)) + assert.Equal(t, 5, runTyped(t, `max(prices)`, env)) + assert.Equal(t, 2.5, runTyped(t, `mean(ratios)`, env)) + assert.Equal(t, 2.5, runTyped(t, `median(ratios)`, env)) + // A map argument mixes freely with array and scalar arguments. + assert.Equal(t, 1, runTyped(t, `min(items, prices)`, env)) + assert.Equal(t, 30, runTyped(t, `max(items, prices)`, env)) +} + +func TestIssue575_works_without_a_typed_environment(t *testing.T) { + env := testEnv() + out := runUntyped(t, `filter(prices, # > 2)`, env) + assert.Equal(t, map[string]any{"banana": 3, "cherry": 5}, asStringMap(t, out)) + + out = runUntyped(t, `map(prices, # * 10)`, env) + assert.Equal(t, map[string]any{"apple": 10, "banana": 30, "cherry": 50}, asStringMap(t, out)) + + out = runUntyped(t, `filter(prices, #index startsWith "b")`, env) + assert.Equal(t, map[string]any{"banana": 3}, asStringMap(t, out)) + + assert.Equal(t, 9, runUntyped(t, `sum(prices)`, env)) + assert.Equal(t, 1, runUntyped(t, `find(prices, # > 0)`, env)) + assert.Equal(t, "abc", runUntyped(t, `reduce(tags, #acc + #index, "")`, env)) + assert.Equal(t, 1, runUntyped(t, `min(prices)`, env)) + assert.Equal(t, 2.5, runUntyped(t, `mean(ratios)`, env)) + + out = runUntyped(t, `groupBy(events, .Author) | filter(len(#) > 1)`, map[string]any{ + "events": []issue575Event{{"ann", 7.0}, {"bob", 9.1}, {"bob", 8.5}}, + }) + m := asStringMap(t, out) + require.Len(t, m, 1) + require.Contains(t, m, "bob") +} + +func TestIssue575_non_string_keyed_maps_are_rejected(t *testing.T) { + env := map[string]any{ + "m": map[string]int{"a": 1}, + "mi": map[int]string{1: "x"}, + } + // String-keyed maps work... + assert.Equal(t, 1, runTyped(t, `sum(m)`, env)) + assert.Equal(t, 1, runTyped(t, `min(m)`, env)) + + // ...while non-string-keyed maps are rejected at compile time with a + // typed environment... + for _, code := range []string{`sum(mi)`, `filter(mi, true)`, `map(mi, #)`, `min(mi)`, `all(mi, true)`} { + _, err := expr.Compile(code, expr.Env(env)) + assert.Error(t, err, code) + } + + // ...and at runtime without one. + for _, code := range []string{`sum(mi)`, `filter(mi, true)`, `min(mi)`} { + program, err := expr.Compile(code) + require.NoError(t, err, code) + _, err = expr.Run(program, env) + assert.Error(t, err, code) + } +} + +func TestIssue575_positional_and_reordering_builtins_still_reject_maps(t *testing.T) { + env := map[string]any{"m": map[string]int{"a": 1, "b": 2}} + // findIndex, findLastIndex, groupBy and sortBy don't translate to map + // entries, since positions and reordering don't make sense for a map. + for _, code := range []string{ + `findIndex(m, # > 0)`, + `findLastIndex(m, # > 0)`, + `groupBy(m, #)`, + `sortBy(m, #)`, + } { + _, err := expr.Compile(code, expr.Env(env)) + assert.Error(t, err, code) + } + // ...while the supported builtins accept maps just fine. + assert.Equal(t, 2, runTyped(t, `findLast(m, # > 0)`, env)) +} + +func TestIssue575_array_behavior_is_unchanged(t *testing.T) { + env := testEnv() + assert.Equal(t, []any{20, 30}, runTyped(t, `filter(items, # > 10)`, env)) + assert.Equal(t, []any{20, 40, 60}, runTyped(t, `map(items, # * 2)`, env)) + assert.Equal(t, []any{0, 1, 2}, runTyped(t, `map(items, #index)`, env)) + assert.Equal(t, 60, runTyped(t, `sum(items)`, env)) + assert.Equal(t, 30, runTyped(t, `findLast(items, # > 10)`, env)) + assert.Equal(t, 10, runTyped(t, `min(items)`, env)) + assert.Equal(t, 20.0, runTyped(t, `mean(items)`, env)) + assert.Equal(t, true, runTyped(t, `one(items, # == 20)`, env)) + assert.Equal(t, 60, runTyped(t, `reduce(items, #acc + #, 0)`, env)) +} diff --git a/testdata/generated.txt b/testdata/generated.txt index 5166aeb82..263e84648 100644 --- a/testdata/generated.txt +++ b/testdata/generated.txt @@ -928,7 +928,6 @@ $env | map(1) | sum(f64) $env | map(1.0) | filter(true) $env | map(1.0) | findLastIndex(ok) $env | map(1.0) | groupBy(foo) -$env | map(1.0) | mean(0) $env | map(1.0) | none(ok) $env | map(1.0) | none(true) $env | map(1.0) | reduce(#) @@ -3600,7 +3599,6 @@ $env?.[String] not matches str $env?.[String] not startsWith str $env?.[String] startsWith $env?.[Bar] $env?.[String] startsWith foo ?? add -$env?.[String] startsWith min($env) $env?.[String] startsWith str $env?.[String] | get(i) $env?.[String]?.$env.list @@ -5447,7 +5445,6 @@ $env?.i == first($env) $env?.i == i $env?.i == len($env) $env?.i == len(list) -$env?.i == max($env) $env?.i > 0 * 1.0 $env?.i > 1 ?? nil $env?.i > f64 @@ -5973,7 +5970,6 @@ $env[:str] not in foo || true -0 != f64 -0 != i -0 != mean(f64) --0 != min($env) -0 != nil -0 % 1 -0 % i @@ -6461,7 +6457,6 @@ $env[:str] not in foo || true -findLastIndex(array, ok) -findLastIndex(array, true) -findLastIndex(list, true) --first($env | map(#index)) -first(array) -float(0) -float(1) @@ -6602,7 +6597,6 @@ $env[:str] not in foo || true -len(foo?.Bar) -len(list) -len(str) --max($env).i -max(0 * 1) -max(0) -max(1) @@ -7418,7 +7412,6 @@ $env[:str] not in foo || true 1 < i > i 1 < i ? ok : ok 1 < i ?? f64 -1 < i or min($env) 1 <= $env.f64 1 <= $env.i 1 <= $env?.f64 @@ -7739,7 +7732,6 @@ $env[:str] not in foo || true 1.0 * 0 >= i 1.0 * 0 ^ f64 1.0 * 1 != i -1.0 * 1 != max($env) 1.0 * 1 ** i 1.0 * 1 ?? add 1.0 * 1 ?? list @@ -10206,8 +10198,6 @@ $env[:str] not in foo || true [map(list, list)] [map(list, ok)] [map(list, str)] -[max($env), f64] -[max($env)] [max(0)] [max(0, 1.0)] [max(1)] @@ -10234,8 +10224,6 @@ $env[:str] not in foo || true [median(f64)] [median(floor(0))] [median(i)] -[min($env ?? foo)] -[min($env)] [min(0)] [min(1)] [min(1.0)] @@ -11152,7 +11140,6 @@ abs(list | findIndex(ok)) abs(list | findIndex(true)) abs(list | reduce(1)) abs(list | sum(1.0)) -abs(max($env)?.f64) abs(max(0)) abs(max(1)) abs(max(1.0)) @@ -11252,7 +11239,6 @@ add != foo ?? $env add != i ?? foo add != list ?? greet add != max(array) -add != min($env) add != nil && $env add != nil == true add != nil ?: 1 @@ -11302,7 +11288,6 @@ add == foo ?? i add == greet ?? $env add == i ?? str add == last($env) -add == max($env) add == mean(array) add == nil != $env?.[Bar] add == nil && $env[foobar:foobar] @@ -11789,7 +11774,6 @@ array != map($env, 1.0) array != map(array, $env) array != map(list, #) array != map(list, 1.0) -array != min($env) array != nil == $env array != nil ? list : greet array != nil and ok @@ -11854,8 +11838,6 @@ array == list != false array == list[:] array == map(array, #) array == map(list, true) -array == max($env)?.greet -array == min($env) array == min(array) array == nil != nil array == nil != ok @@ -12600,7 +12582,6 @@ array | max(f64, 1.0) array | max(f64, f64) array | max(i) array | max(i, i) -array | max(map($env, #index)) array | mean(-1.0) array | mean(0 / 1) array | mean(0) @@ -13872,12 +13853,6 @@ ceil(sum(array, #)) ceil(sum(list, 1.0)) ceil(sum(list, f64)) ceil({foo: 1.0}?.foo) -concat($env | map(1.0)) -concat($env | map(1.0))[:i] -concat($env | map(add)) -concat($env | map(array)) -concat($env | map(foo)) -concat($env | map(greet)) concat($env.array) concat($env.list) concat($env?.array | sortBy(#)) @@ -13979,7 +13954,6 @@ concat(concat(array, array)) concat(concat(list)) concat(false ?: array) concat(false ?: list) -concat(filter($env, false)) concat(flatten($env.array)) concat(flatten(array)) concat(flatten(list)) @@ -14035,15 +14009,6 @@ concat(list, list) concat(list, list) | any(true) concat(list, list)?.[i] concat(list[1:]) -concat(map($env, #index)) -concat(map($env, $env)) -concat(map($env, 1.0)) -concat(map($env, array)) -concat(map($env, array), list) -concat(map($env, f64)) -concat(map($env, foo)) -concat(map($env, list)) -concat(map($env, ok)) concat(map(array, #)) concat(map(array, $env)) concat(map(array, 1)) @@ -15013,7 +14978,6 @@ f64 == max(0) f64 == mean(1.0) f64 == median(1.0) f64 == median(i) -f64 == min($env) f64 == min(1.0) f64 == nil != nil f64 == nil != ok @@ -15506,7 +15470,6 @@ f64 not in array || sum($env) f64 not in i .. i f64 not in keys($env) f64 not in list ?? str -f64 not in map($env, $env) f64 not in map(array, 1) f64 | max(0) f64 | max(0, 1) @@ -16457,7 +16420,6 @@ find(map($env, $env), foo == #) find(map(array, 1.0), i != 1.0) find(map(list, #), ok) find(map(list, 0), ok) -find(max($env), $env == i) find(nil ?? array, # > #) find(nil ?? list, # == #) find(sort($env), #.array.f64) @@ -16564,7 +16526,6 @@ findIndex(list, str not contains str) findIndex(list, true) | min(1.0) findIndex(map($env, $env), #.String contains #) findIndex(map($env, $env), ok) ?? add -findIndex(max($env), i > f64) findIndex(nil ?? $env, ok) findIndex(sort($env), #.String?.list(foobar)) findIndex(sort($env), #.f64 - #.i) @@ -16724,7 +16685,6 @@ findLast(list, true).String findLast(list, true).String() findLast(list, true)?.Bar findLast(map(list, ok), #) -findLast(min($env), .array and false) findLast(sort($env), #) findLast(sort($env), .array) findLast(sort($env), .f64 != #.i) @@ -17231,13 +17191,9 @@ first(map(list, 0)) first(map(list, array)) first(map(list, f64)) first(map(list, foo)) -first(max($env)) first(max(array)) first(mean(array)) first(median(array)) -first(min($env)) -first(min($env))?.add -first(min($env)?.f64) first(min(array)) first(min(array, 1, 1)) first(nil ?? $env) @@ -17284,13 +17240,6 @@ first(values($env)) first({foo: 1.0}.f64) first({foo: greet, foo: nil}.ok) first({foo: i, foo: f64}.String) -flatten($env | map($env)) -flatten($env | map(1.0)) -flatten($env | map(add)) -flatten($env | map(array)) -flatten($env | map(foo)) -flatten($env | map(i)) -flatten($env | map(list)) flatten($env.array) flatten($env.list) flatten($env?.array) @@ -17425,11 +17374,6 @@ flatten(list)[:] flatten(list[0:]) flatten(list[1:]) flatten(list[:]) -flatten(map($env, $env)) -flatten(map($env, 0)) -flatten(map($env, 1.0)) -flatten(map($env, f64)) -flatten(map($env, false)) flatten(map(array, f64)) flatten(map(array, i)) flatten(map(array, true)) @@ -17773,7 +17717,6 @@ float(median(array)) float(median(array, f64)) float(median(f64)) float(median(i)) -float(min($env).f64) float(min($env?.array)) float(min(0)) float(min(0, 1)) @@ -18931,10 +18874,8 @@ fromPairs($env).true && false fromPairs([list]) fromPairs(array | take(0)) fromPairs(array[:0]) -fromPairs(filter($env, false)) fromPairs(filter(list, false)) fromPairs(list[:0]) -fromPairs(map($env, list)) fromPairs(sort($env)) fromPairs(take(array, 0)) fromPairs(toPairs($env)) @@ -19026,7 +18967,6 @@ greet != greet ?: greet greet != greet or ok greet != greet || $env greet != greet || false -greet != max($env) greet != nil == nil greet != nil ? foo : true greet != nil ?? $env @@ -19069,7 +19009,6 @@ greet == greet && $env greet == greet == ok greet == greet ? 1.0 : foo greet == greet and $env -greet == min($env) greet == nil != nil greet == nil != ok greet == nil == true @@ -19262,7 +19201,6 @@ greet in first($env) greet in flatten(list) greet in list ?? $env?.[array] greet in list ?? $env?.[i] -greet in map($env, $env) greet in reverse(list) greet in sort($env) greet in toPairs($env) @@ -19318,7 +19256,6 @@ greet(greet(type(true))) greet(if false { 1.0 } else { str }) greet(if ok { str } else { nil }) greet(if true { str } else { $env }) -greet(join($env | map(str))) greet(keys($env)?.[i]) greet(last(list).Bar) greet(let x = str; x) @@ -19329,7 +19266,6 @@ greet(list?.[i].Bar) greet(list?.[i].String()) greet(lower(greet(str))) greet(lower(str)) -greet(min($env)?.str) greet(nil ?? str) greet(ok ? str : 1) greet(reduce(array, str)) @@ -20559,7 +20495,6 @@ i != list ?? str i != max(1.0) i != mean(f64) i != median(1.0) -i != min($env) i != min(0) i != min(i) i != nil != $env @@ -21339,7 +21274,6 @@ i == max(array) i == max(i) i == mean(1.0) i == median(f64) -i == min($env) i == min(f64) i == nil == $env i == nil ? $env?.[i] : list @@ -21826,7 +21760,6 @@ i in groupBy(list, #)?.i i in i .. 0 i in last($env) i in list ?? str -i in map($env, f64) i in sort(array) i in toPairs($env) i in {foo: 1.0}.greet @@ -22433,7 +22366,6 @@ int(true ? 1 : array) int(true ? 1.0 : $env) int(true ? f64 : foo) int(true ? i : nil) -join($env | filter(false)) join([str]) join(array | map(str)) join(keys($env)) @@ -22501,8 +22433,6 @@ keys(groupBy(list, ok)) keys(if true { $env } else { str }) keys(list | groupBy(#)) keys(list | groupBy(1)) -keys(max($env)) -keys(min($env)) keys(nil ?? $env) keys(reduce(list, $env)) keys({foo: $env}) @@ -22913,7 +22843,6 @@ last(list)?.String() last(list[1:]) last(list[:1]) last(map($env, $env)) -last(map($env, $env)).ok last(map($env, 1)) last(map($env, 1.0)) last(map($env, f64)) @@ -22930,17 +22859,14 @@ last(map(list, 1.0)) last(map(list, array)) last(map(list, foo)) last(map(list, i)) -last(max($env)) last(max($env?.Bar)) last(max(0, array)) last(max(array)) last(mean(array)) last(median(array)) last(median(i, array)) -last(min($env)) last(min(array)) last(min(array, 1.0)) -last(min(if true { $env } else { f64 })) last(nil ?? $env) last(nil ?? array) last(ok ? list : ok) @@ -23119,7 +23045,6 @@ len(false ?: str) len(filter($env, ok)) len(flatten(array)) len(flatten(list)) -len(flatten(map($env, $env))) len(foo.Bar) len(foo.String()) len(foo?.Bar) @@ -23203,8 +23128,6 @@ len(map(list, #index)) len(map(list, add)) len(map(list, f64)) len(map(list, true)) -len(max($env)) -len(min($env)) len(nil ?? array) len(nil ?? str) len(ok ? list : $env) @@ -23653,7 +23576,6 @@ list != list and false list != list or false list != map(list, #) list != map(list, str) -list != max($env) list != median(array) list != min(array) list != nil ? 1.0 : f64 @@ -25139,7 +25061,6 @@ map($env, #index) | filter(false) map($env, #index) | map(greet) map($env, #index) | reduce($env) map($env, #index) | reduce(0, $env) -map($env, #index)?.[i] map($env, $env) ?? timezone(str) map($env, $env) in last($env) map($env, $env) | any(f64 == .foo) @@ -25151,7 +25072,6 @@ map($env, $env) | reduce($env) map($env, $env) | reduce(.f64, false) map($env, $env) | reduce(0) map($env, $env) | sum(1) -map($env, $env)?.[i] map($env, 0) != i .. 0 map($env, 0) ?? greet map($env, 0) ?? list @@ -25159,7 +25079,6 @@ map($env, 0) | map($env) map($env, 0) | map(str) map($env, 0) | reduce(ok, i) map($env, 0) | sortBy(1.0) -map($env, 0)?.[i] map($env, 1) | any(ok) map($env, 1) | count(ok) map($env, 1) | groupBy(foo) @@ -25167,7 +25086,6 @@ map($env, 1) | map(#) map($env, 1) | none(ok) map($env, 1) | reduce(foo) map($env, 1) | sum(#) -map($env, 1)?.[i] map($env, 1.0) ?? f64 map($env, 1.0) | all(true) map($env, 1.0) | filter(ok) @@ -25175,17 +25093,14 @@ map($env, 1.0) | find(true) map($env, 1.0) | groupBy(#) map($env, 1.0) | map(str) map($env, 1.0) | reduce(ok, add) -map($env, 1.0)?.[i] map($env, add) | groupBy(i) map($env, add) | map(false) map($env, add) | reduce(1.0) map($env, add) | reduce(add, false) map($env, add) | sum(f64) -map($env, add)?.[i] map($env, array) | findIndex(ok) map($env, array) | map(0) map($env, array) | reduce(f64) -map($env, array)?.[i] map($env, f64) | count(ok) map($env, f64) | count(true) map($env, f64) | groupBy(#) @@ -25195,22 +25110,18 @@ map($env, f64) | reduce(greet) map($env, f64) | sortBy(#) map($env, f64) | sortBy(i) map($env, f64) | sum(#) -map($env, f64)?.[i] -map($env, f64)[:] map($env, false) | filter(false) map($env, false) | find(#) map($env, false) | map(#) map($env, false) | map(1.0) map($env, false) | map(greet) map($env, false) | reduce(str, nil) -map($env, false)?.[i] map($env, foo) == list map($env, foo) ?? list map($env, foo) ?? str map($env, foo) not in [add, f64] map($env, foo) | all(.Bar not startsWith .Bar) map($env, foo) | findLastIndex(true) -map($env, foo) | get(1) map($env, foo) | groupBy(#) map($env, foo) | map($env) map($env, foo) | map(f64) @@ -25222,7 +25133,6 @@ map($env, foo) | reduce(#) map($env, foo) | reduce(list) map($env, foo) | sortBy(.Bar) map($env, foo) | sortBy(str) -map($env, foo)?.[i] map($env, greet) ?? list map($env, greet) | all(ok) map($env, greet) | findLast(false) @@ -25230,13 +25140,11 @@ map($env, greet) | map(1) map($env, greet) | map(greet) map($env, greet) | reduce(list) map($env, greet) | sortBy(i) -map($env, greet)?.[i] map($env, i) == list map($env, i) | any(true) map($env, i) | groupBy(f64) map($env, i) | one(ok) map($env, i) | sortBy(1.0) -map($env, i)?.[i] map($env, list) ?? str map($env, list) | filter(false) map($env, list) | map($env) @@ -25245,19 +25153,16 @@ map($env, list) | map(str) map($env, list) | reduce(1.0) map($env, list) | reduce(foo) map($env, list) | reduce(list) -map($env, list)?.[i] map($env, ok) | findLastIndex(#) map($env, ok) | findLastIndex(false) map($env, ok) | one(1.0 == nil) map($env, ok) | reduce(false) -map($env, ok)?.[i] map($env, str) | find(ok) map($env, str) | find(true) map($env, str) | findIndex(false) map($env, str) | groupBy(#) map($env, str) | map(1) map($env, str) | sortBy(str) -map($env, str)?.[i] map($env, true) ?? ok map($env, true) | any(#) map($env, true) | filter(true) @@ -25267,7 +25172,6 @@ map($env, true) | map(#) map($env, true) | map(f64) map($env, true) | reduce(foo) map($env, true) | sortBy(1.0) -map($env, true)?.[i] map($env.array, #) map($env.array, #index - 1.0) map($env.array, #index) @@ -25854,8 +25758,6 @@ map(map(array, list), str) map(map(list, 1), list) map(map(list, f64), ok) map(map(list, str), map($env, 1.0)) -map(max($env), greet) -map(min($env), f64) map(nil ?? $env, str) map(nil ?? array, 1.0 != 1.0) map(reduce($env, list, $env), foo) @@ -25885,16 +25787,6 @@ map(true ? array : false, $env?.greet) map(uniq(list), ok) map(uniq(list), reduce(list, foo)) map(values($env), #) -max($env ?? $env) -max($env ?? 1) -max($env ?? add) -max($env ?? array) -max($env ?? f64) -max($env ?? foo) -max($env ?? list) -max($env ?? nil) -max($env ?? str) -max($env ?? true) max($env | count(ok)) max($env | count(true)) max($env | find(false)) @@ -25903,78 +25795,9 @@ max($env | findLast(false)) max($env | findLastIndex(false)) max($env | findLastIndex(ok)) max($env | findLastIndex(true)) -max($env | map(#index)) max($env | map(0)) max($env | map(1)) max($env | sum(i)) -max($env) != $env?.array -max($env) != greet -max($env) == $env?.foo -max($env) == f64 -max($env) == list -max($env) == ok -max($env) ?? i -max($env) ?? list -max($env) not in list -max($env) | all(ok) -max($env) | count($env.ok) -max($env) | count(false) -max($env) | count(true) -max($env) | find(false) -max($env) | map(1.0) -max($env) | map(add) -max($env) | map(f64) -max($env) | map(greet) -max($env) | map(i) -max($env) | map(str) -max($env) | none(ok) -max($env) | sum(0) -max($env) | sum(1.0) -max($env).Bar -max($env).Bar?.i().str -max($env).Bar?.str -max($env).String -max($env).String?.[array] -max($env).String?.[str] -max($env).add -max($env).array -max($env).f64 -max($env).foo -max($env).foobar -max($env).foobar?.[add] -max($env).foobar?.[greet] -max($env).foobar?.foo -max($env).foobar?.str -max($env).greet -max($env).greet(foobar) -max($env).i -max($env).list -max($env).not -max($env).ok -max($env).str -max($env)?.$env?.Bar -max($env)?.$env?.foo(1.0) -max($env)?.Bar -max($env)?.String -max($env)?.String?.[str] -max($env)?.[str] -max($env)?.[str]?.[i] -max($env)?.add -max($env)?.array -max($env)?.array not in list -max($env)?.f64 -max($env)?.foo -max($env)?.foobar?.[add] -max($env)?.foobar?.[greet] -max($env)?.greet -max($env)?.greet(foobar) -max($env)?.i -max($env)?.list -max($env)?.list?.[f64] -max($env)?.not -max($env)?.ok -max($env)?.str -max($env)?.str?.[f64] max($env.array ?? array) max($env.array) max($env.array, array) @@ -26187,8 +26010,6 @@ max(array | get(i)) max(array | map(1.0)) max(array | map(i)) max(array | reduce(#index)) -max(array | reduce($env)) -max(array | reduce($env, 0)) max(array | reduce(0)) max(array | reduce(1.0, foo)) max(array | reduce(i)) @@ -26283,7 +26104,6 @@ max(f64, i) max(f64, median(0)) max(f64, min(1, 1)) max(false ? foo : 0) -max(false ? nil : $env) max(false ? nil : array) max(false ? str : 0) max(false ?: foo) @@ -26386,10 +26206,8 @@ max(i..i) max(if false { 0 } else { nil }) max(if false { array } else { nil }) max(if false { ok } else { 1.0 }) -max(if ok { $env } else { 1.0 }) max(if ok { foo } else { 1.0 }) max(if ok { ok } else { 0 }) -max(if true { $env } else { f64 }) max(int(0)) max(int(1)) max(int(1.0)) @@ -26404,14 +26222,11 @@ max(len(array)) max(len(list)) max(len(str)) max(let foobar = f64; foobar) -max(let tmp = $env; tmp) max(list | count(ok)) max(list | count(true)) max(list | filter(false)) max(list | map(f64)) -max(list | reduce($env)) max(list) startsWith str and false -max(map($env, #index)) max(map($env, 0)) max(map($env, 1.0)) max(map($env, array)) @@ -26421,7 +26236,6 @@ max(map(array, array)) max(map(array, f64)) max(map(list, 1)) max(map(list, i)) -max(max($env)) max(max($env.array)) max(max($env?.Bar)) max(max(0)) @@ -26449,7 +26263,6 @@ max(median(array)) max(median(array, 1)) max(median(f64)) max(median(i)) -max(min($env)) max(min(0)) max(min(1)) max(min(1.0)) @@ -26458,7 +26271,6 @@ max(min(1.0, f64, i)) max(min(array)) max(min(f64)) max(min(i)) -max(nil ?? $env) max(nil ?? 0) max(nil ?? 1) max(nil ?? 1.0) @@ -26475,7 +26287,6 @@ max(ok ?? i) max(reduce($env, i, foo)) max(reduce(array, #)) max(reduce(array, #acc)) -max(reduce(array, $env)) max(reduce(array, $env).String) max(reduce(array, 1.0)) max(reduce(list, 0)) @@ -26517,9 +26328,6 @@ max(uniq(array)) max({foo: $env}?.add) max({foo: true, foo: str}?.list) mean($env | count(ok)) -mean($env | filter(false)) -mean($env | map(0)) -mean($env | map(1.0)) mean($env | reduce(1, 1.0)) mean($env | reduce(1.0, greet)) mean($env | reduce(array, foo)) @@ -26798,7 +26606,6 @@ mean(f64, sum(array)) mean(false ? add : 1.0) mean(false ? i : f64) mean(false ?: 1) -mean(filter($env, false)) mean(filter(array, ok)) mean(find(array, ok)) mean(findIndex($env, true)) @@ -26896,10 +26703,6 @@ mean(list | reduce(1)) mean(list | reduce(array)) mean(list | reduce(f64)) mean(list | sum(1)) -mean(map($env, 1)) -mean(map($env, 1.0)) -mean(map($env, array)) -mean(map($env, f64)) mean(map(array, #)) mean(map(array, 0)) mean(map(array, f64)) @@ -26982,9 +26785,6 @@ mean(uniq(array)) median($env | count(false)) median($env | findIndex(true)) median($env | findLastIndex(ok)) -median($env | map(1)) -median($env | map(1.0)) -median($env | map(i)) median($env | reduce(f64, greet)) median($env | sum(0)) median($env | sum(1.0)) @@ -27365,10 +27165,6 @@ median(list | map(#index)) median(list | map(array)) median(list | reduce(i)) median(list | sum(0)) -median(map($env, 0)) -median(map($env, 1)) -median(map($env, 1.0)) -median(map($env, f64)) median(map(array, 1)) median(map(list, 0)) median(map(list, 1.0)) @@ -27437,16 +27233,6 @@ median(true ? 0 : ok) median(true ? i : foo) median(uniq(array)) median({foo: 1.0}?.foo) -min($env ?? 0) -min($env ?? 1)?.ok -min($env ?? add) -min($env ?? f64) -min($env ?? false) -min($env ?? foo) -min($env ?? greet) -min($env ?? i) -min($env ?? ok) -min($env ?? true) min($env | filter(false)) min($env | findIndex(false)) min($env | findIndex(true)) @@ -27459,71 +27245,8 @@ min($env | reduce(array, 1)) min($env | sum(1.0)) min($env | sum(f64)) min($env | sum(i)) -min($env) != array -min($env) == greet -min($env) == i -min($env) == ok -min($env) ?? uniq($env) -min($env) matches $env?.foobar min($env) not contains $env || true -min($env) not in list -min($env) | all(false) -min($env) | any(false) -min($env) | count(ok) -min($env) | map(1) -min($env) | map(add) -min($env) | map(f64) -min($env) | map(false) -min($env) | map(foo) -min($env) | map(ok) -min($env) | reduce(0, 1) -min($env) | reduce(1.0, $env) -min($env) | sum(0) -min($env) | sum(1.0) -min($env).Bar -min($env).Bar?.greet() -min($env).String -min($env).String?.[greet] -min($env).add -min($env).array -min($env).f64 -min($env).false?.add -min($env).false?.f64 -min($env).foo -min($env).foobar -min($env).foobar?.i(foobar, foobar) -min($env).foobar?.ok -min($env).greet -min($env).i min($env).i && false -min($env).list -min($env).nil?.foobar -min($env).ok -min($env).str -min($env)?.$env?.Bar() -min($env)?.Bar -min($env)?.Bar?.foobar.foo().list -min($env)?.String -min($env)?.String?.foo -min($env)?.[str] -min($env)?.[str] not startsWith str -min($env)?.add -min($env)?.array -min($env)?.f64 -min($env)?.foo -min($env)?.foobar -min($env)?.foobar?.String() -min($env)?.foobar?.foo.i -min($env)?.foobar?.list -min($env)?.foobar?.str -min($env)?.greet -min($env)?.greet(foobar) -min($env)?.i -min($env)?.list -min($env)?.not -min($env)?.ok -min($env)?.str -min($env)?.true?.f64(foobar) min($env.array) min($env.array, sum(array)) min($env.f64) @@ -27713,7 +27436,6 @@ min(1.0, 1.0) + i min(1.0, 1.0) - 0 < 1.0 min(1.0, 1.0) ^ f64 min(1.1) -min([$env] | reduce(.f64)) min([0]) min([1.0]) min([1]) @@ -27850,7 +27572,6 @@ min(false ? 0 : greet) min(false ? 0 : nil) min(false ? 1 : 1.0) min(false ? array : nil) -min(false ? foo : $env).i min(false ? greet : foo) min(false ?: 0) min(false ?? 1.0) @@ -27967,7 +27688,6 @@ min(list | findIndex(false)) min(list | map(1)) min(list | map(1.0)) min(list | map(i)) -min(list | reduce($env)) min(list | reduce(1)) min(list | reduce(1.0)) min(list | reduce(array)) @@ -27981,7 +27701,6 @@ min(map(array, #index)) min(map(array, 1.0)) min(map(list, #index)) min(map(list, f64)) -min(max($env)) min(max(0)) min(max(0, 1)) min(max(0, 1.0, 0)) @@ -28005,7 +27724,6 @@ min(median(1.0)) min(median(array)) min(median(f64)) min(median(i)) -min(min($env)) min(min(0)) min(min(1)) min(min(1.0)) @@ -28014,7 +27732,6 @@ min(min(f64)) min(min(f64, 1.0)) min(min(f64, 1.0, 1.0)) min(min(i)) -min(nil ?? $env) min(nil ?? f64) min(nil ?? i) min(ok ? array : true) @@ -28023,7 +27740,6 @@ min(ok ?? 1) min(ok ?? i) min(ok ?? toJSON(list)) min(reduce(array, #)) -min(reduce(array, $env))?.str min(reduce(array, 1.0)) min(reduce(array, i)) min(reduce(list, 1.0)) @@ -28600,7 +28316,6 @@ not false || false not false || last($env) not false || ok not false || true -not max($env).ok not nil ?? false not nil ?? ok not nil ?? true @@ -28925,7 +28640,6 @@ ok && i >= 1 ok && i not in array ok && list == $env ok && list ?? greet -ok && max($env) ok && max(array) ok && mean(array) ok && median(array) @@ -29004,7 +28718,6 @@ ok == list ?? greet ok == list ?? ok ok == max(array) ok == mean(array) -ok == min($env) ok == nil != false ok == nil != ok ok == nil != true @@ -29622,7 +29335,6 @@ ok not in groupBy(list, i) ok not in last($env) ok not in list ?? add ok not in list ?? greet -ok not in map($env, false) ok not in values($env) ok or !false ok or !ok @@ -30193,7 +29905,6 @@ one(list, str < #.Bar) one(list, str >= str) one(list, true != $env) one(list, true ?? foo) -one(min($env), ok) one(sort($env), $env == nil) one(sort($env), .String.list(nil)) one(sort($env), .greet[:.i]) @@ -30895,7 +30606,6 @@ reduce(map(list, #), #) reduce(map(list, #), $env?.add) reduce(map(list, .Bar), list) reduce(map(list, i), #) -reduce(max($env).array, ok) reduce(nil ?? list, add) reduce(reduce(array, list), #index) reduce(reduce(list, array), i) @@ -30917,14 +30627,6 @@ repeat(str, i) repeat(str, i) not startsWith str repeat(str, max(1.0, array)) repeat(type(1), i) -reverse($env | filter(false)) -reverse($env | map($env)) -reverse($env | map(0)) -reverse($env | map(1.0)) -reverse($env | map(false)) -reverse($env | map(foo)) -reverse($env | map(ok)) -reverse($env | map(true)) reverse($env.array) reverse($env.list) reverse($env?.array) @@ -31014,7 +30716,6 @@ reverse(array[i:1]) reverse(concat(array)) reverse(concat(list)) reverse(false ? array : list) -reverse(filter($env, false)) reverse(filter(list, ok)) reverse(flatten(array)) reverse(flatten(list)) @@ -31069,16 +30770,6 @@ reverse(list) | reduce(true) reverse(list) | sum(f64) reverse(list)?.[i] reverse(list[:i]) -reverse(map($env, $env)) -reverse(map($env, 0)) -reverse(map($env, 1.0)) -reverse(map($env, add)) -reverse(map($env, array)) -reverse(map($env, f64)) -reverse(map($env, foo)) -reverse(map($env, list)) -reverse(map($env, str)) -reverse(map($env, true)) reverse(map(array, #)) reverse(map(array, #index)) reverse(map(array, 1.0)) @@ -31148,7 +30839,6 @@ round(0) != i round(0) - i round(0) / f64 round(0) < i -round(0) == max($env) round(0) ?? foo round(0) ^ f64 round(0) ^ i @@ -31794,12 +31484,10 @@ sort(map(array, 1.0)) sort(map(list, #.Bar)) sort(map(list, 1.0)) sort(map(list, str)) -sort(max($env)) sort(max(array)) sort(max(array, 1.0)) sort(mean(array)) sort(median(array)) -sort(min($env)) sort(min(array)) sort(min(array, 1.0)) sort(nil ?? $env) @@ -32213,7 +31901,6 @@ str + repeat(str, 1) str + str str + str != $env str + str ?? foo -str + str in max($env) str + str not in $env str + string($env) str + string(add) @@ -32327,7 +32014,6 @@ str == foo?.String() str == greet(str) str == i ?? array str == list ?? foo -str == max($env) str == nil == ok str == nil ?: list str == nil or true || $env @@ -32783,7 +32469,6 @@ str in foo and array != array str in foo and false str in foo || $env str in list?.[i] -str in min($env) str in sort(array) str in {foo: $env?.[Bar]} str in {foo: 1} @@ -33976,7 +33661,6 @@ string(map(list, $env)) string(map(list, greet)) string(map(list, list)) string(map(list, true)) -string(max($env)) string(max(0)) string(max(1)) string(max(1.0)) @@ -33999,7 +33683,6 @@ string(median(f64)) string(median(i)) string(median(i, 0)) string(median(round(i))) -string(min($env)) string(min(0)) string(min(0, 1.0)) string(min(1)) @@ -34305,7 +33988,6 @@ string({foo: true}?.array) sum($env ?? greet, f64) sum($env | filter(false)) sum($env | filter(false), reduce(#, #)) -sum($env | map(#index)) sum($env | map(0)) sum($env | map(1)) sum($env | map(1.0)) @@ -34627,7 +34309,6 @@ sum(list, mean(i)) sum(list, round(1.0)) sum(list[i:0]) sum(list[i:i]) -sum(map($env, #index)) sum(map($env, $env), i) sum(map($env, 0) | sortBy(1.0)) sum(map($env, 0)) @@ -34647,9 +34328,7 @@ sum(map(list, 1)) sum(map(list, 1.0)) sum(map(list, f64)) sum(map(list, i)) -sum(max($env).array) sum(max($env?.[str])) -sum(min($env), f64) sum(nil ?? array) sum(ok ? array : false) sum(ok ? array : foo) @@ -35523,7 +35202,6 @@ toJSON(median(array, array)) toJSON(median(f64)) toJSON(median(f64, i)) toJSON(median(i)) -toJSON(min($env)?.list) toJSON(min(0)) toJSON(min(1)) toJSON(min(1, f64)) @@ -35886,8 +35564,6 @@ toPairs(list | groupBy(#)) toPairs(list | groupBy(false)) toPairs(list | groupBy(foo)) toPairs(list | groupBy(i)) -toPairs(max($env)) -toPairs(min($env)) toPairs(nil ?? $env) toPairs(reduce(array, $env)) toPairs({foo: $env, foo: true}) @@ -37675,7 +37351,6 @@ type(map(list, #)) type(map(list, 1.0)) type(map(list, false)) type(map(list, foo)) -type(max($env)) type(max(0)) type(max(0, array)) type(max(0, f64)) @@ -37700,7 +37375,6 @@ type(median(f64)) type(median(f64, 1.0)) type(median(i)) type(median(min(f64))) -type(min($env)) type(min(0)) type(min(1)) type(min(1.0)) @@ -38024,15 +37698,6 @@ type({foo: str, foo: str}) type({foo: str}) type({foo: toJSON(foo)}) type({foo: true}) -uniq($env | filter(false)) -uniq($env | map(#index)) -uniq($env | map(0)) -uniq($env | map(1.0)) -uniq($env | map(add)) -uniq($env | map(f64)) -uniq($env | map(foo)) -uniq($env | map(ok)) -uniq($env | map(str)) uniq($env | reduce(array, foo)) uniq($env.array) uniq($env.list) @@ -38178,14 +37843,6 @@ uniq(list)[$env.i:] uniq(list[1:]) uniq(list[:i]) uniq(list[i:]) -uniq(map($env, 1.0)) -uniq(map($env, array)) -uniq(map($env, foo)) -uniq(map($env, i)) -uniq(map($env, list)) -uniq(map($env, ok)) -uniq(map($env, str)) -uniq(map($env, true)) uniq(map(array, #)) uniq(map(array, 1.0)) uniq(map(array, array)) @@ -38356,8 +38013,6 @@ values(if ok { $env } else { str }) values(list | groupBy(#)) values(list | groupBy(.Bar)) values(list | groupBy(false)) -values(max($env)) -values(min($env)) values(nil ?? $env) values(ok ? $env : nil) values(reduce(array, $env)) @@ -41727,7 +41382,6 @@ values({foo: true}) {foo: greet}?.ok?.list {foo: greet}?.str {foo: groupBy(array, #)} -{foo: groupBy(array, 0), foo: min($env)} {foo: groupBy(array, 1.0)} {foo: groupBy(array, f64)} {foo: groupBy(array, false)} @@ -42339,9 +41993,6 @@ values({foo: true}) {foo: map(list, foo)} {foo: map(list, greet)} {foo: map(list, ok)} -{foo: max($env), foo: array} -{foo: max($env)} -{foo: max($env)}.i {foo: max(0)} {foo: max(1)} {foo: max(1.0)} @@ -42375,8 +42026,6 @@ values({foo: true}) {foo: median(flatten(array))} {foo: median(i)} {foo: median(i, array)}.Bar -{foo: min($env)?.String} -{foo: min($env)} {foo: min(0)} {foo: min(1), foo: ok} {foo: min(1)} diff --git a/vm/opcodes.go b/vm/opcodes.go index 5fca0fa29..a83d3c1b9 100644 --- a/vm/opcodes.go +++ b/vm/opcodes.go @@ -86,5 +86,8 @@ const ( OpBegin OpAnd OpOr + OpPointerIndex + OpMarkKey + OpCollect OpEnd // This opcode must be at the end of this list. ) diff --git a/vm/program.go b/vm/program.go index 7eb96bd3d..0fbad1f62 100644 --- a/vm/program.go +++ b/vm/program.go @@ -381,6 +381,15 @@ func (program *Program) DisassembleWriter(w io.Writer) { case OpOr: code("OpOr") + case OpPointerIndex: + code("OpPointerIndex") + + case OpMarkKey: + code("OpMarkKey") + + case OpCollect: + code("OpCollect") + case OpEnd: code("OpEnd") diff --git a/vm/utils.go b/vm/utils.go index 7f1ca1e89..f7df903c6 100644 --- a/vm/utils.go +++ b/vm/utils.go @@ -2,6 +2,7 @@ package vm import ( "reflect" + "sort" "time" ) @@ -20,6 +21,12 @@ type Scope struct { Len int Count int Acc any + // Map iteration state. Keys holds the sorted keys of the iterated map; + // it is non-nil if and only if the scope iterates over a map. Marked + // collects the keys of elements kept by collecting builtins. + Keys []string + Marked []string + Map map[string]any // Fast path for map[string]any // Fast paths Ints []int Floats []float64 @@ -41,9 +48,29 @@ func (s *Scope) Item() any { if s.Anys != nil { return s.Anys[s.Index] } + if s.Keys != nil { + key := s.Keys[s.Index] + if s.Map != nil { + return s.Map[key] + } + k := reflect.ValueOf(key) + if kt := s.Array.Type().Key(); kt.Kind() != reflect.Interface { + k = k.Convert(kt) + } + return s.Array.MapIndex(k).Interface() + } return s.Array.Index(s.Index).Interface() } +func sortedKeys(m map[string]any) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + type groupBy = map[any][]any type Span struct { diff --git a/vm/vm.go b/vm/vm.go index ba3b53863..c1038d84a 100644 --- a/vm/vm.go +++ b/vm/vm.go @@ -544,6 +544,37 @@ func (vm *VM) Run(program *Program, env any) (_ any, err error) { case OpPointer: vm.push(vm.currScope.Item()) + case OpPointerIndex: + scope := vm.currScope + if scope.Keys != nil { + vm.push(scope.Keys[scope.Index]) + } else { + vm.push(scope.Index) + } + + case OpMarkKey: + scope := vm.currScope + if scope.Keys != nil { + scope.Marked = append(scope.Marked, scope.Keys[scope.Index]) + } + + case OpCollect: + size := vm.pop().(int) + vm.memGrow(uint(size)) + if scope := vm.currScope; scope.Keys != nil { + m := make(map[string]any, size) + for i := size - 1; i >= 0; i-- { + m[scope.Marked[i]] = vm.pop() + } + vm.push(m) + } else { + array := make([]any, size) + for i := size - 1; i >= 0; i-- { + array[i] = vm.pop() + } + vm.push(array) + } + case OpThrow: panic(vm.pop().(error)) @@ -621,9 +652,33 @@ func (vm *VM) Run(program *Program, env any) (_ any, err error) { case []any: s.Anys = v s.Len = len(v) + case map[string]any: + s.Map = v + s.Keys = sortedKeys(v) + s.Len = len(v) default: - s.Array = reflect.ValueOf(a) - s.Len = s.Array.Len() + r := reflect.ValueOf(a) + if r.Kind() == reflect.Map { + keys := make([]string, 0, r.Len()) + iter := r.MapRange() + for iter.Next() { + k := iter.Key() + if k.Kind() == reflect.Interface { + k = k.Elem() + } + if k.Kind() != reflect.String { + panic(fmt.Errorf("cannot iterate over map with non-string keys (%T)", a)) + } + keys = append(keys, k.String()) + } + sort.Strings(keys) + s.Array = r + s.Keys = keys + s.Len = len(keys) + } else { + s.Array = r + s.Len = r.Len() + } } vm.Scopes = append(vm.Scopes, s) vm.currScope = s @@ -715,6 +770,10 @@ func (vm *VM) allocScope() *Scope { s.Floats = nil s.Strings = nil s.Anys = nil + // Clear map iteration state + s.Keys = nil + s.Marked = nil + s.Map = nil // Clear Array to release reference for GC (only matters for fallback path) s.Array = reflect.Value{} return s